diff --git a/.changeset/2026-08-26-execution-context.md b/.changeset/2026-08-26-execution-context.md new file mode 100644 index 0000000..5f619e3 --- /dev/null +++ b/.changeset/2026-08-26-execution-context.md @@ -0,0 +1,5 @@ +--- +"@dexpace/core": patch +--- + +Internal: execution context promotion chain and bounded store for product-spec §7 (CTX-1..20). No public API change. diff --git a/docs/open-items.md b/docs/open-items.md index cc9fcc7..07ec5f4 100644 --- a/docs/open-items.md +++ b/docs/open-items.md @@ -1,9 +1,10 @@ # Open Items Running register of everything known to be unmet, unverified, misreported, or deliberately deferred across the -implemented portion of this project. Reviewed state: **scaffold milestone** (committed, `0ebdc79`) and +implemented portion of this project. Reviewed state: **scaffold milestone** (committed, `0ebdc79`), **Phase 1 — Core HTTP Domain Model** (branch `2-phase-1-core-http-domain-model`, uncommitted at time of -review). Last reviewed **2026-07-30**. +review), and **Phase 4a — Execution Context** (branch `7-phase-4a-execution-context`, three review passes). +Last reviewed **2026-08-26**. A requirement absent from this file is either satisfied or belongs to a phase that has not started. The point of the file is that nothing is unmet *silently* — every gap below is either scheduled against a named phase or @@ -84,6 +85,63 @@ add an import scan over `packages/core/src` allowing only relative specifiers an --- +### A5 — CTX-8: the duplicate-key error's *message* does not identify the key — **DECIDE** + +Appendix C states CTX-8 more strictly than `product-spec/07` §7.3 does. §7.3 says the reject-on-duplicate +insert "fails all others with an error naming the key"; appendix C +(`appendix-c-consolidated-normative-requirement-index.md:176`) says "an error **whose message** identifies the +key." + +`DuplicateContextKeyError`'s message is `` `context key already registered: ${String(key)}` ``. Call keys are +`Symbol()`s whose description is the flavor, not the identity, so every default-constructed context of a given +flavor renders identically: + +``` +context key already registered: Symbol(dispatch-context) +``` + +The message therefore names the *kind* of key, not *which* key. The error does carry the offending symbol as a +`readonly key: symbol` field — strictly more identifying than any string, and asserted in +`store.test.ts` — so the requirement's intent is met by the field while its letter is not met by the message. + +Phase 4a's design already ledgers the `Symbol()` key choice with the cost "debuggability (opaque when logged or +printed)", but that row does not connect itself to CTX-8's message clause, so nothing currently records this as +a known partial deviation. + +Two ways out, both defensible: +1. **Give default keys a distinguishing description** — `Symbol('dispatch-context#' + n)` from a module-scoped + counter. The counter would label only the description; `Symbol()` remains the identity, so CTX-4/5/6's + uniqueness is untouched and the ledger's rejection of a `traceId:spanId`+counter *string key* still stands. + Costs a second module-level mutable binding (`docs/knowledge/variables-and-declarations.md:22`), on top of + the `contextStore` singleton that already takes that deviation. +2. **Record a deliberate partial deviation** in the Phase 4a design's Deviation Ledger, on the grounds that a + symbol has no unique rendering and the typed `.key` field identifies the key more precisely than a message + can. + +Either way the Phase 4 checklist's CTX-8 row should stop reading as an unqualified ✅. + +### A6 — CTX-12 / XCUT-14: the drain **loop**'s shape is unverifiable, and untested — **WATCH** + +`ContextStore.#drain` is a post-insert loop, as CTX-12 (SHOULD) and XCUT-14 (MUST) require. No test proves it +is a loop, and none can: `install` and `installIfAbsent` each set exactly one key before draining, so the map +is never more than one over the cap at drain entry and a second pass is unreachable. Replacing the loop body +with a single check-then-evict breaks nothing — confirmed by mutation testing across the module (that mutant is +the only meaningful survivor of 23). + +The Phase 4a plan's Self-Review claims CTX-12 is covered by "a property-style burst test [that] asserts the +size never overshoots after any single insert". That test is real and passing, but it pins the **bound**, not +the drain's shape. + +Not a defect today: the loop is present, the bound holds, and on a single-threaded runtime the two shapes are +behaviorally identical. `#drain` and the drain `describe` block both now carry a note saying so, so the loop is +not "simplified" away by a later reader. + +**Trigger:** a runtime where inserts can stack more than one overshoot before a drain runs (worker threads, a +future concurrent store), or any change that lets the map exceed `cap + 1`. At that point the shape becomes +observable and owes a real test. + +--- + ## B. Gates and tooling ### B1 — NFR-10 / NFR-17: CI never runs on the declared minimum runtime — **ACT** (trigger has now fired) @@ -144,6 +202,21 @@ not deliberate reflection abuse (`Object.create(Request.prototype)`), and states `sdk-design-nodejs/10-deliberate-deviations-from-the-reference-contract.md` when that phase is reached." Listed here so the promise survives until then. +### C3 — The Phase 4 checklist under-reports Phase 4a — **ACT** + +`docs/superpowers/plans/2026-07-26-phase4-execution-context-and-pipelines-checklist.md` still carries its +banner: "the plans are reviewed and corrected as of 2026-07-26 but **not yet executed**. Every ✅ means 'the +plan builds and tests it,' not 'it is on `main`.'" Phase 4a's rows are now built, tested, and committed on +`7-phase-4a-execution-context`, so the banner understates them while 4b and 4c remain unbuilt. + +The same checklist maps only `CTX-*`. It has no `XCUT-14` row, even though +`docs/superpowers/specs/2026-07-28-phase9-cross-cutting-conformance-design.md:66` names "4a's context registry" +as an XCUT-14 site and appendix B's only conformance row that `ContextStore` satisfies is B.8's +"Caller/server-keyed maps bounded with drain-to-cap loop (XCUT-14)" — appendix B has no CTX section at all. The +ID is now cited in `store.ts` and `store.test.ts`; the checklist is the remaining gap. + +Split the banner per sub-phase, and add an `XCUT-14` row pointing at 4a Task 4 (qualified by A6 above). + --- ## D. Scheduled deferrals @@ -161,7 +234,11 @@ No action now. Each is already owned by a named phase; this table exists so none | Seam contracts (byte-stream, transport, codec, projection) | SEAM-2 – SEAM-30 | 2–8 | | | Adapter packages, peer-dependency dedup | NFR-2 | 8 | | | Shrink-survival regression guard | NFR-9 | 9 | | -| Concurrency-model agnosticism check | NFR-11 | 4 | No async code exists yet | +| Concurrency-model agnosticism check | NFR-11 | 4c | Retargeted from "Phase 4" by the 4a design: everything in 4a is synchronous, and 4c's stage pipeline is where async-facing surface appears | +| `CTX-17`'s positive half — the first store entry installed by the first promotion | CTX-17 | 4c | 4a satisfies only the negative half (constructing a head context must not auto-register it), which holds structurally because `context.ts` never imports `store.ts`. Wiring the store into the promotions would invert the layering and make every promotion a global side effect | +| Real W3C Trace Context generation behind `InstrumentationBundle` | CTX-14, CTX-15 | 7 | 4a ships the bundle's frozen shape and the no-op default only. `activeSpan`/`tracerFactory` stay typed `unknown`, and `activeSpan` is `undefined` rather than a no-op span object, until a tracing adapter defines `Span` | +| `contextsEqual()`, value equality over `ExecutionContext` | CTX-5 (equality framing) | none | Built only if 4b or 4c needs one. `CTX-5`'s operative half — pinning an explicit shared key — ships via `ContextInit.key` | +| `FakeTransport` test double | — | 4c | 4a never touches `Transport`; `PIPE-9`'s empty-pipeline dispatch is the likely first real consumer | | Self-identifying version metadata (real `User-Agent`) | NFR-15 | 7/8 | | | Publish + provenance CI job | NFR-16 | release | `prepublishOnly` wired; nothing published yet | | NFR-8 re-confirmed as a documented non-applicability | NFR-8 | 10 | No reflection-driven discovery surface exists by design | diff --git a/packages/core/src/context/context.test.ts b/packages/core/src/context/context.test.ts new file mode 100644 index 0000000..5cd576b --- /dev/null +++ b/packages/core/src/context/context.test.ts @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/context/context.test.ts +// Exercises: CTX-1 (one-way promotion, incl. the compile-time no-promote-back check), CTX-2 (additive, +// non-mutating, carries forward instrumentation + key), CTX-3 (one shared call key across the whole +// chain), CTX-5/CTX-6 (off-chain construction, fresh key per default call at population scale, explicit +// key pinning), CTX-7 (immutable), CTX-15 (keys stay call-unique though every bundle field is identical), +// CTX-16 (operationName absent at dispatch, introduced at request, carried forward, never keyed on) +import {describe, expect, test} from 'bun:test'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import { + type DispatchContext, + createDispatchContext, + createExchangeContext, + createRequestContext, + promoteToExchange, + promoteToRequest, +} from './context.js'; +import {noopInstrumentationBundle} from './instrumentation.js'; + +function aRequest(): Request { + return Request.newBuilder().url('https://example.com').build(); +} + +function aResponse(request: Request): Response { + return Response.newBuilder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .build(); +} + +describe('promotion chain (CTX-1, CTX-2, CTX-3)', () => { + test('dispatch exposes exactly its expected artifacts', () => { + const dispatch = createDispatchContext(); + expect(dispatch.kind).toBe('dispatch'); + expect(dispatch.key).toBeDefined(); + expect(dispatch.instrumentation).toBe(noopInstrumentationBundle); + }); + + test('promoting dispatch to request adds exactly the request, carrying key and instrumentation forward by reference', () => { + const dispatch = createDispatchContext(); + const request = aRequest(); + const requestCtx = promoteToRequest(dispatch, request, 'GetWidget'); + + expect(requestCtx.kind).toBe('request'); + expect(requestCtx.key).toBe(dispatch.key); + expect(requestCtx.instrumentation).toBe(dispatch.instrumentation); + expect(requestCtx.request).toBe(request); + expect(requestCtx.operationName).toBe('GetWidget'); + }); + + test('the source context is unchanged by promotion', () => { + const dispatch = createDispatchContext(); + const before = {...dispatch}; + promoteToRequest(dispatch, aRequest()); + expect(dispatch).toEqual(before); + }); + + test('promoting request to exchange adds exactly the response, carrying everything else forward', () => { + const request = aRequest(); + const requestCtx = promoteToRequest( + createDispatchContext(), + request, + 'GetWidget', + ); + const response = aResponse(request); + const exchangeCtx = promoteToExchange(requestCtx, response); + + expect(exchangeCtx.kind).toBe('exchange'); + expect(exchangeCtx.key).toBe(requestCtx.key); + expect(exchangeCtx.instrumentation).toBe(requestCtx.instrumentation); + expect(exchangeCtx.operationName).toBe('GetWidget'); + expect(exchangeCtx.request).toBe(request); + expect(exchangeCtx.response).toBe(response); + }); + + test('the whole chain shares one call key across all three flavors', () => { + const dispatch = createDispatchContext(); + const requestCtx = promoteToRequest(dispatch, aRequest()); + const exchangeCtx = promoteToExchange( + requestCtx, + aResponse(requestCtx.request), + ); + expect(requestCtx.key).toBe(dispatch.key); + expect(exchangeCtx.key).toBe(dispatch.key); + }); +}); + +describe('promotion is one-way (CTX-1)', () => { + test('no promotion function accepts an ExchangeContext, so there is no way back', () => { + const requestCtx = promoteToRequest(createDispatchContext(), aRequest()); + const exchangeCtx = promoteToExchange( + requestCtx, + aResponse(requestCtx.request), + ); + + // CTX-1's "the exchange type exposes no method promoting back" is a compile-time guarantee in this + // design, not a runtime one: promoteToRequest/promoteToExchange are free functions typed to accept + // only DispatchContext/RequestContext respectively, and there is no third promotion function. These + // two @ts-expect-error lines are the assertion -- `bun run typecheck` FAILS if either promotion ever + // widens to accept a terminal context, which a prose-only comment would not catch. + // @ts-expect-error -- ExchangeContext is terminal; it is not a DispatchContext + promoteToRequest(exchangeCtx, aRequest()); + // @ts-expect-error -- ExchangeContext is terminal; it is not a RequestContext + promoteToExchange(exchangeCtx, aResponse(requestCtx.request)); + + expect(exchangeCtx.kind).toBe('exchange'); + }); +}); + +describe('off-chain construction (CTX-5, CTX-6)', () => { + test('default construction mints a fresh, distinct key every call', () => { + const a = createDispatchContext(); + const b = createDispatchContext(); + expect(a.key).not.toBe(b.key); + }); + + test('N default-constructed contexts across all three flavors are pairwise key-distinct', () => { + // CTX-5's "globally distinct across the whole process and all three flavors" is a property over the + // whole population, not just a pair -- a keying scheme that collided every Nth call would pass the + // pairwise test above. Every bundle field is identical here (all use noopInstrumentationBundle), so + // this is also CTX-15's "call-key derivation MUST remain call-unique even when every bundle field is + // identical" at scale. + const request = aRequest(); + const keys = new Set(); + for (let i = 0; i < 1000; i += 1) { + keys.add(createDispatchContext().key); + keys.add(createRequestContext(request).key); + keys.add(createExchangeContext(request, aResponse(request)).key); + } + expect(keys.size).toBe(3000); + }); + + test('an explicit key can be pinned so two contexts share one slot', () => { + const key = Symbol('shared'); + const a = createDispatchContext({key}); + const b = createDispatchContext({key}); + expect(a.key).toBe(b.key); + }); + + test('an explicit instrumentation bundle is carried onto the context verbatim', () => { + const instrumentation = { + ...noopInstrumentationBundle, + traceId: 'a'.repeat(32), + isValid: true, + }; + expect(createDispatchContext({instrumentation}).instrumentation).toBe( + instrumentation, + ); + }); + + test('a caller-supplied instrumentation bundle is frozen by the factory (CTX-7)', () => { + // Object.freeze on the context is shallow, so without this the bundle behind `instrumentation` stays + // writable and the caller can mutate a "immutable" context out from under the whole chain. + const instrumentation = { + ...noopInstrumentationBundle, + traceId: 'a'.repeat(32), + }; + const dispatch = createDispatchContext({instrumentation}); + + expect(Object.isFrozen(dispatch.instrumentation)).toBe(true); + expect(Object.isFrozen(instrumentation)).toBe(true); // frozen in place, so the reference stays shared + }); + + test('createRequestContext and createExchangeContext also default to a fresh key per call', () => { + const request = aRequest(); + const a = createRequestContext(request); + const b = createRequestContext(request); + expect(a.key).not.toBe(b.key); + + const c = createExchangeContext(request, aResponse(request)); + const d = createExchangeContext(request, aResponse(request)); + expect(c.key).not.toBe(d.key); + }); +}); + +describe('operationName (CTX-16)', () => { + test('is absent at the dispatch stage', () => { + expect('operationName' in createDispatchContext()).toBe(false); + }); + + test('defaults to undefined when not supplied at promotion', () => { + const requestCtx = promoteToRequest(createDispatchContext(), aRequest()); + expect(requestCtx.operationName).toBeUndefined(); + }); + + test('is carried forward unchanged across the request-to-exchange promotion', () => { + const requestCtx = promoteToRequest( + createDispatchContext(), + aRequest(), + 'GetWidget', + ); + const exchangeCtx = promoteToExchange( + requestCtx, + aResponse(requestCtx.request), + ); + expect(exchangeCtx.operationName).toBe('GetWidget'); + }); + + test('is advisory only -- it never influences the call key', () => { + // CTX-16: "never influencing the request, dispatch decision, or store key." Two otherwise-identical + // promotions differing only in operationName keep their source keys; and pinning one key across two + // different operation names still yields one slot, proving the name is not folded into it. + const key = Symbol('shared'); + const a = promoteToRequest( + createDispatchContext({key}), + aRequest(), + 'GetWidget', + ); + const b = promoteToRequest( + createDispatchContext({key}), + aRequest(), + 'DeleteWidget', + ); + expect(a.key).toBe(b.key); + expect(a.operationName).not.toBe(b.operationName); + }); +}); + +describe('immutability (CTX-7)', () => { + test('a promotion freezes a bundle that never passed through a factory', () => { + // The context flavors are interfaces, not classes, so 4b/4c can hand a promotion a + // literal-constructed context whose bundle was never frozen. Without this the promoted context is + // "immutable" in name only: the caller keeps a writable reference to its trace state. + const instrumentation = { + ...noopInstrumentationBundle, + traceId: 'a'.repeat(32), + }; + const forged: DispatchContext = { + kind: 'dispatch', + key: Symbol('forged'), + instrumentation, + }; + + const requestCtx = promoteToRequest(forged, aRequest()); + + expect(Object.isFrozen(requestCtx.instrumentation)).toBe(true); + expect(requestCtx.instrumentation).toBe(instrumentation); // frozen in place -- CTX-2 still holds + expect( + Object.isFrozen( + promoteToExchange(requestCtx, aResponse(requestCtx.request)) + .instrumentation, + ), + ).toBe(true); + }); + + test('every context flavor is frozen', () => { + const dispatch = createDispatchContext(); + expect(Object.isFrozen(dispatch)).toBe(true); + const requestCtx = promoteToRequest(dispatch, aRequest()); + expect(Object.isFrozen(requestCtx)).toBe(true); + expect( + Object.isFrozen( + promoteToExchange(requestCtx, aResponse(requestCtx.request)), + ), + ).toBe(true); + }); +}); diff --git a/packages/core/src/context/context.ts b/packages/core/src/context/context.ts new file mode 100644 index 0000000..99f6ce2 --- /dev/null +++ b/packages/core/src/context/context.ts @@ -0,0 +1,198 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/context/context.ts +import type {Request} from '../http/request.js'; +import type {Response} from '../http/response.js'; +import { + noopInstrumentationBundle, + type InstrumentationBundle, +} from './instrumentation.js'; + +/** + * Before any request (CTX-1). No `operationName` — CTX-16 introduces it at the request stage. + * + * @internal + */ +export interface DispatchContext { + readonly kind: 'dispatch'; + readonly key: symbol; + readonly instrumentation: InstrumentationBundle; +} + +/** + * An outgoing request assembled (CTX-1). + * + * @internal + */ +export interface RequestContext { + readonly kind: 'request'; + readonly key: symbol; + readonly instrumentation: InstrumentationBundle; + readonly operationName: string | undefined; + readonly request: Request; +} + +/** + * A response arrived; terminal — no further promotion exists (CTX-1). + * + * @internal + */ +export interface ExchangeContext { + readonly kind: 'exchange'; + readonly key: symbol; + readonly instrumentation: InstrumentationBundle; + readonly operationName: string | undefined; + readonly request: Request; + readonly response: Response; +} + +/** + * The three promotion-chain stages as one discriminated union, branched on `kind`. + * + * @internal + */ +export type ExecutionContext = + DispatchContext | RequestContext | ExchangeContext; + +/** + * Optional inputs shared by the three off-chain `create*` factories. One options object rather than + * positional parameters: `createExchangeContext` would otherwise take five, and ESLint's `max-params` is 3 + * and counts optional parameters. Every field is spelled `?: T | undefined` for + * `exactOptionalPropertyTypes`. + * + * @internal + */ +export interface ContextInit { + /** Advisory operation label (CTX-16); never influences the request, dispatch, or store key. */ + readonly operationName?: string | undefined; + /** @defaultValue `noopInstrumentationBundle` */ + readonly instrumentation?: InstrumentationBundle | undefined; + /** + * Pin to make two contexts share one store slot (CTX-5). + * + * @defaultValue a fresh `Symbol()` per call + */ + readonly key?: symbol | undefined; +} + +/** + * Off-chain construction (CTX-5): `key` defaults to a fresh Symbol() per call unless pinned, which is also + * what makes default keys globally distinct across the process and all three flavors (CTX-6). Takes + * `Omit` — CTX-16 introduces the operation name at the request stage, so the + * dispatch factory does not offer it. + * + * @internal + */ +export function createDispatchContext( + init: Omit = {}, +): DispatchContext { + const { + instrumentation = noopInstrumentationBundle, + key = Symbol('dispatch-context'), + } = init; + return Object.freeze({ + kind: 'dispatch', + key, + instrumentation: freezeBundle(instrumentation), + }); +} + +/** + * Off-chain construction (CTX-5/6) — see `promoteToRequest` for the normal promotion path. + * + * @internal + */ +export function createRequestContext( + request: Request, + init: ContextInit = {}, +): RequestContext { + const { + operationName, + instrumentation = noopInstrumentationBundle, + key = Symbol('request-context'), + } = init; + return Object.freeze({ + kind: 'request', + key, + instrumentation: freezeBundle(instrumentation), + operationName, + request, + }); +} + +/** + * Off-chain construction (CTX-5/6) — see `promoteToExchange` for the normal promotion path. + * + * @internal + */ +export function createExchangeContext( + request: Request, + response: Response, + init: ContextInit = {}, +): ExchangeContext { + const { + operationName, + instrumentation = noopInstrumentationBundle, + key = Symbol('exchange-context'), + } = init; + return Object.freeze({ + kind: 'exchange', + key, + instrumentation: freezeBundle(instrumentation), + operationName, + request, + response, + }); +} + +/** + * dispatch -\> request (CTX-1/2/3): adds the request, carries key + instrumentation forward verbatim — + * `freezeBundle` is idempotent and freezes in place, so the bundle reference CTX-2 carries forward is + * unchanged; it is re-run because `DispatchContext` is an interface, so a caller can hand a + * literal-constructed context whose bundle never passed through a `create*` factory. + * + * @internal + */ +export function promoteToRequest( + context: DispatchContext, + request: Request, + operationName?: string, +): RequestContext { + return Object.freeze({ + kind: 'request', + key: context.key, + instrumentation: freezeBundle(context.instrumentation), + operationName, + request, + }); +} + +/** + * request -\> exchange (CTX-1/2/3): adds the response, carries everything else forward verbatim; the + * bundle is re-frozen for the same reason as `promoteToRequest`. + * + * @internal + */ +export function promoteToExchange( + context: RequestContext, + response: Response, +): ExchangeContext { + return Object.freeze({ + kind: 'exchange', + key: context.key, + instrumentation: freezeBundle(context.instrumentation), + operationName: context.operationName, + request: context.request, + response, + }); +} + +/** + * CTX-7: a context must be immutable, but `Object.freeze` on the context object is shallow, so a + * caller-supplied bundle would stay writable behind the `instrumentation` slot. Frozen in place rather than + * copied, so the reference the promotions carry forward (CTX-2) is the one the caller handed in. + * `noopInstrumentationBundle` is already frozen, so the default path costs nothing. Idempotent, which is + * what lets both the factories and the two promotions call it unconditionally. + */ +function freezeBundle(bundle: InstrumentationBundle): InstrumentationBundle { + return Object.isFrozen(bundle) ? bundle : Object.freeze(bundle); +} diff --git a/packages/core/src/context/errors.test.ts b/packages/core/src/context/errors.test.ts new file mode 100644 index 0000000..efea243 --- /dev/null +++ b/packages/core/src/context/errors.test.ts @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/context/errors.test.ts +// Exercises: CTX-8 (reject-on-duplicate insert failure, naming the key) +import {describe, expect, test} from 'bun:test'; +import {DexpaceError} from '../http/errors.js'; +import {DuplicateContextKeyError} from './errors.js'; + +describe('DuplicateContextKeyError', () => { + test('descends from DexpaceError and names the offending key', () => { + const key = Symbol('call-1'); + const error = new DuplicateContextKeyError(key); + expect(error).toBeInstanceOf(DexpaceError); + expect(error.key).toBe(key); + expect(error.message).toContain('call-1'); + }); + + test('sets name from its own constructor', () => { + expect(new DuplicateContextKeyError(Symbol('x')).name).toBe( + 'DuplicateContextKeyError', + ); + }); + + test('cause chains through', () => { + const cause = new Error('boom'); + expect(new DuplicateContextKeyError(Symbol('x'), {cause}).cause).toBe( + cause, + ); + }); +}); diff --git a/packages/core/src/context/errors.ts b/packages/core/src/context/errors.ts new file mode 100644 index 0000000..eacab55 --- /dev/null +++ b/packages/core/src/context/errors.ts @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/context/errors.ts +import {DexpaceError} from '../http/errors.js'; + +/** + * `installIfAbsent` found the key already occupied (CTX-8). + * + * @internal + */ +export class DuplicateContextKeyError extends DexpaceError { + readonly key: symbol; + + constructor(key: symbol, options?: ErrorOptions) { + super(`context key already registered: ${String(key)}`, options); + this.key = key; + } +} diff --git a/packages/core/src/context/instrumentation.test.ts b/packages/core/src/context/instrumentation.test.ts new file mode 100644 index 0000000..acd5f3f --- /dev/null +++ b/packages/core/src/context/instrumentation.test.ts @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/context/instrumentation.test.ts +// Exercises: CTX-14 (bundle shape), CTX-15 (no-op default: invalid sentinels, isValid/isRemote false, +// no-op span/tracer factory), CTX-20 (tracer factory safe to invoke concurrently, emits nothing) +import {describe, expect, test} from 'bun:test'; +import {noopInstrumentationBundle} from './instrumentation.js'; + +describe('noopInstrumentationBundle (CTX-15)', () => { + test('reserves all-zero trace/span ids and zero flags', () => { + expect(noopInstrumentationBundle.traceId).toBe( + '00000000000000000000000000000000', + ); + expect(noopInstrumentationBundle.spanId).toBe('0000000000000000'); + expect(noopInstrumentationBundle.traceFlags).toBe(0); + expect(noopInstrumentationBundle.traceState).toBe(''); + }); + + test('names its trace-id encoding flavor', () => { + // CTX-14 requires the flavor field; CTX-15 fixes no sentinel for it, so the disabled bundle says + // 'none' rather than claiming an encoding it never produced ids in. + expect(noopInstrumentationBundle.traceIdEncoding).toBe('none'); + }); + + test('is invalid and not remote', () => { + expect(noopInstrumentationBundle.isValid).toBe(false); + expect(noopInstrumentationBundle.isRemote).toBe(false); + }); + + // CTX-15 says "a no-op span". With `activeSpan` typed `unknown` until a real tracing adapter lands + // (Phase 7), there is no Span shape to build a no-op instance of, so absence is the encoding. Logged as a + // partial deviation in the design's Deviation Ledger -- revisit when the adapter defines Span. + test('has no active span', () => { + expect(noopInstrumentationBundle.activeSpan).toBeUndefined(); + }); + + test('tracerFactory emits nothing and is safe to invoke repeatedly (CTX-20)', () => { + expect(noopInstrumentationBundle.tracerFactory('op-a')).toBeUndefined(); + expect(noopInstrumentationBundle.tracerFactory('op-b')).toBeUndefined(); + }); + + test('is frozen', () => { + expect(Object.isFrozen(noopInstrumentationBundle)).toBe(true); + }); +}); diff --git a/packages/core/src/context/instrumentation.ts b/packages/core/src/context/instrumentation.ts new file mode 100644 index 0000000..8a14524 --- /dev/null +++ b/packages/core/src/context/instrumentation.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/context/instrumentation.ts + +/** + * Correlation/instrumentation bundle every execution context carries (CTX-14). `activeSpan` and + * `tracerFactory` are typed `unknown` rather than a Span/Tracer interface — nothing in this phase + * consumes either, and a real tracing adapter (deferred to Phase 7) owns their eventual shape. + * + * @internal + */ +export interface InstrumentationBundle { + readonly traceId: string; + readonly spanId: string; + readonly traceFlags: number; + readonly traceState: string; + readonly traceIdEncoding: string; + readonly isValid: boolean; + readonly isRemote: boolean; + readonly activeSpan: unknown; + readonly tracerFactory: (operationName: string) => unknown; +} + +/** + * The disabled-tracing default (CTX-15): reserved invalid sentinels, no-op span and tracer factory. Every + * field is constant, so call-key uniqueness (CTX-4) must not depend on any of them — see `context.ts`'s + * `Symbol()`-based keys. + * + * @internal + */ +export const noopInstrumentationBundle: InstrumentationBundle = Object.freeze({ + traceId: '00000000000000000000000000000000', + spanId: '0000000000000000', + traceFlags: 0, + traceState: '', + traceIdEncoding: 'none', + isValid: false, + isRemote: false, + activeSpan: undefined, + tracerFactory: () => undefined, +}); diff --git a/packages/core/src/context/store.test.ts b/packages/core/src/context/store.test.ts new file mode 100644 index 0000000..f040c9f --- /dev/null +++ b/packages/core/src/context/store.test.ts @@ -0,0 +1,269 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/context/store.test.ts +// Exercises: CTX-3 (all three flavors collapse to one slot, successive promotions overwriting it), +// CTX-4 (two contexts sharing identical trace AND span id get distinct keys and both +// register), CTX-8 (install-or-replace never throws; reject-on-duplicate fails naming the key), +// CTX-9/CTX-10 (identity-conditional close, intermediate-link close is a no-op), CTX-11/CTX-12 (bounded, +// post-insert drain loop), CTX-17 (a never-promoted dispatch context leaves no entry; its close is a +// harmless no-op), CTX-13 (arbitrary victim; no entry is promised to survive), CTX-18 (unknown-key +// lookup/close are well-defined no-ops), CTX-19 (strong refs), +// XCUT-14 (a caller-keyed process-lived map -- "context registries" is the requirement's own first +// example -- carries a hard cap and a post-insert drain loop, and a burst never leaves it stuck above) +// +// Every test builds its own `new ContextStore()`. The exported `contextStore` singleton is module-level +// mutable state shared by every test file in a `bun test` run -- 4c's runtime.test.ts installs into that +// same object -- so an absolute `size` assertion against it reads a counter a sibling file can move, and a +// blanket clear() wipes a sibling's entries. docs/knowledge/testing.md:50,52. The singleton gets exactly +// one assertion here: that it is a ContextStore. +import {describe, expect, test} from 'bun:test'; +import {Protocol} from '../http/protocol.js'; +import {Request} from '../http/request.js'; +import {Response} from '../http/response.js'; +import {Status} from '../http/status.js'; +import {InvariantViolation} from '../invariant.js'; +import { + createDispatchContext, + promoteToExchange, + promoteToRequest, +} from './context.js'; +import {DuplicateContextKeyError} from './errors.js'; +import {ContextStore, contextStore} from './store.js'; + +function aRequest(): Request { + return Request.newBuilder().url('https://example.com').build(); +} + +function aResponse(request: Request): Response { + return Response.newBuilder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .status(Status.of(200)) + .build(); +} + +describe('install / installIfAbsent (CTX-8)', () => { + test('install never throws and is retrievable by key', () => { + const store = new ContextStore(); + const context = createDispatchContext(); + store.install(context); + expect(store.get(context.key)).toBe(context); + }); + + test('install unconditionally overwrites an existing occupant', () => { + const store = new ContextStore(); + const context = createDispatchContext(); + store.install(context); + const promoted = promoteToRequest(context, aRequest()); + store.install(promoted); + expect(store.get(context.key)).toBe(promoted); + }); + + test('installIfAbsent succeeds when the key is free', () => { + const store = new ContextStore(); + const context = createDispatchContext(); + store.installIfAbsent(context); + expect(store.get(context.key)).toBe(context); + }); + + test('installIfAbsent on an occupied key throws DuplicateContextKeyError naming the key', () => { + const store = new ContextStore(); + const context = createDispatchContext(); + store.installIfAbsent(context); + const other = createDispatchContext({ + instrumentation: context.instrumentation, + key: context.key, + }); + + let caught: unknown; + try { + store.installIfAbsent(other); + } catch (error) { + caught = error; + } + + // CTX-8 says the error names the key, so assert the field, not only the class -- the store passing + // the wrong symbol through would otherwise be invisible here, and a symbol does not survive the + // message-substring check the rest of the suite uses for named-field errors. + expect(caught).toBeInstanceOf(DuplicateContextKeyError); + expect((caught as DuplicateContextKeyError).key).toBe(context.key); + }); + + test('a rejected installIfAbsent leaves the incumbent in the slot', () => { + // CTX-8's "admits exactly one winner": the loser must not have displaced or corrupted the winner. + const store = new ContextStore(); + const winner = createDispatchContext(); + store.installIfAbsent(winner); + const loser = createDispatchContext({key: winner.key}); + + expect(() => { + store.installIfAbsent(loser); + }).toThrow(DuplicateContextKeyError); + + expect(store.get(winner.key)).toBe(winner); + expect(store.size).toBe(1); + }); +}); + +describe('call-key uniqueness under an identical bundle (CTX-4)', () => { + test('two contexts sharing identical trace AND span id get distinct keys and both register', () => { + // §7's own Conformance clause for CTX-4, transcribed. Both contexts carry the very same + // noopInstrumentationBundle -- identical traceId, spanId, flags, state -- which is exactly the + // disabled-tracing case CTX-15 warns about. Symbol() keys make them distinct anyway, so neither + // evicts the other. + const store = new ContextStore(); + const a = createDispatchContext(); + const b = createDispatchContext(); + expect(a.instrumentation).toBe(b.instrumentation); + expect(a.key).not.toBe(b.key); + + store.install(a); + store.install(b); + expect(store.get(a.key)).toBe(a); + expect(store.get(b.key)).toBe(b); + expect(store.size).toBe(2); + }); +}); + +describe('one slot for the whole chain (CTX-3)', () => { + test('all three flavors register under the identical slot, each promotion overwriting the last', () => { + // CTX-3's store-level clause: "all three flavors register under the identical store slot and + // successive promotions overwrite one entry." Asserted here rather than in context.test.ts, which + // can only show the keys match -- that they collapse to ONE entry needs a store. + const store = new ContextStore(); + const dispatch = createDispatchContext(); + const request = aRequest(); + const requestCtx = promoteToRequest(dispatch, request, 'GetWidget'); + const exchangeCtx = promoteToExchange(requestCtx, aResponse(request)); + + store.install(dispatch); + store.install(requestCtx); + store.install(exchangeCtx); + + expect(store.size).toBe(1); + expect(store.get(dispatch.key)).toBe(exchangeCtx); + }); +}); + +describe('no auto-registration at construction (CTX-17)', () => { + test('a freshly constructed dispatch context is not in the store', () => { + const store = new ContextStore(); + const context = createDispatchContext(); + expect(store.get(context.key)).toBeUndefined(); + expect(store.size).toBe(0); + }); + + test('promoting registers nothing either, and closing the unregistered source is a harmless no-op', () => { + const store = new ContextStore(); + const context = createDispatchContext(); + promoteToRequest(context, aRequest()); // promotion alone registers nothing in 4a -- see below + expect(store.size).toBe(0); + expect(() => { + store.close(context); + }).not.toThrow(); + }); + + // CTX-17's other half -- "the first store entry is installed by the first promotion" -- is NOT + // satisfied here: promoteToRequest/promoteToExchange are pure and never touch the store, so an + // explicit store.install(...) is what registers anything. That call belongs to 4c's pipeline, + // which owns the store handle. Tracked as a deferral in this plan's Self-Review, not an omission. +}); + +describe('close (CTX-9, CTX-10)', () => { + test('evicts when the closing context is the current occupant', () => { + const store = new ContextStore(); + const context = createDispatchContext(); + store.install(context); + store.close(context); + expect(store.get(context.key)).toBeUndefined(); + }); + + test('closing an intermediate link already superseded by promotion is a no-op', () => { + const store = new ContextStore(); + const dispatch = createDispatchContext(); + store.install(dispatch); + const promoted = promoteToRequest(dispatch, aRequest()); + store.install(promoted); // furthest-reached link now occupies the slot + + store.close(dispatch); // intermediate link -- must not evict the live promoted occupant + expect(store.get(dispatch.key)).toBe(promoted); + }); + + test('closing an unknown or already-removed key is a well-defined no-op (CTX-18)', () => { + const store = new ContextStore(); + const context = createDispatchContext(); + expect(() => { + store.close(context); + }).not.toThrow(); + store.install(context); + store.close(context); + expect(() => { + store.close(context); + }).not.toThrow(); + }); +}); + +describe('lookup (CTX-18)', () => { + test('an unknown key returns undefined, never throws', () => { + expect(new ContextStore().get(Symbol('unknown'))).toBeUndefined(); + }); +}); + +describe('bounded drain (CTX-11, CTX-12, CTX-13)', () => { + // These pin the BOUND, not the drain's shape. `install`/`installIfAbsent` each set one key before + // draining, so the map is never more than one over the cap and a single check-then-evict would pass + // every assertion here -- verified by mutation. CTX-12/XCUT-14's loop is retained for runtimes where + // concurrent inserts stack overshoots; see the note on `#drain`. + + test('a burst of inserts past the cap converges the store to at or under the cap', () => { + const store = new ContextStore(5); + for (let i = 0; i < 50; i += 1) { + store.install(createDispatchContext()); + expect(store.size).toBeLessThanOrEqual(5); // drains after every single insert, never overshoots + } + // Negative space: bounding only from above would also pass for a store that retained nothing at all. + // 50 distinct keys against a cap of 5 must leave the store saturated, not empty. + expect(store.size).toBe(5); + }); + + test('installIfAbsent also drains after a successful insert', () => { + const store = new ContextStore(2); + for (let i = 0; i < 10; i += 1) { + store.installIfAbsent(createDispatchContext()); + } + expect(store.size).toBe(2); + }); + + test('a cap below 1 is rejected at construction', () => { + // The constructor is the only place this is checked, which is what lets #drain skip an unreachable + // in-loop undefined guard. A bad cap is a violated precondition -- a programmer error -- so it fails + // through invariant (assertions.md:4, error-handling.md:36), not an ad-hoc throw. + expect(() => new ContextStore(0)).toThrow(InvariantViolation); + expect(() => new ContextStore(-1)).toThrow(InvariantViolation); + expect(() => new ContextStore(1.5)).toThrow(InvariantViolation); + expect(() => new ContextStore(1)).not.toThrow(); + }); +}); + +describe('clear', () => { + test('drops every entry, leaving the store reusable', () => { + const store = new ContextStore(); + const kept = createDispatchContext(); + store.install(kept); + store.install(createDispatchContext()); + + store.clear(); + + expect(store.size).toBe(0); + expect(store.get(kept.key)).toBeUndefined(); + store.install(kept); // still usable afterwards -- clear() resets entries, not the cap + expect(store.get(kept.key)).toBe(kept); + }); +}); + +describe('the process-wide singleton', () => { + test('is a real ContextStore instance', () => { + // The only assertion this file makes against the singleton: it is shared with every other test file + // in the run, so nothing behavioural may be asserted through it. + expect(contextStore).toBeInstanceOf(ContextStore); + }); +}); diff --git a/packages/core/src/context/store.ts b/packages/core/src/context/store.ts new file mode 100644 index 0000000..54e103f --- /dev/null +++ b/packages/core/src/context/store.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +// packages/core/src/context/store.ts +import {invariant} from '../invariant.js'; +import type {ExecutionContext} from './context.js'; +import {DuplicateContextKeyError} from './errors.js'; + +// Backstop cap (CTX-11, XCUT-14); a leaked context pins its whole request/response graph, including a +// possibly unread body holding a connection. +const DEFAULT_MAX_ENTRIES = 10_000; + +/** + * A bounded, keyed store of in-flight execution contexts (CTX-7..13, CTX-18, CTX-19). Also the textbook + * subject of `XCUT-14`, which names "context registries" first among the caller-keyed process-lived maps + * that MUST carry a hard cap and drain back under it in a loop after each insert — an unbounded one is a + * memory-exhaustion vector, not merely a leak. Thread-safety is + * satisfied by construction: Node's single-threaded event loop means no two synchronous Map mutations + * ever interleave, collapsing the reference's concurrent-map requirement into a plain Map. The Map holds + * strong references — never WeakRef/WeakMap — so a registered context keeps its whole Request+Response + * graph reachable and the cap, not the collector, is the leak backstop (CTX-19). + * + * @internal + */ +export class ContextStore { + readonly #entries = new Map(); + readonly #maxEntries: number; + + /** + * @throws InvariantViolation when `maxEntries` is not a positive integer — a violated precondition, + * never an operational failure a caller recovers from. + */ + constructor(maxEntries: number = DEFAULT_MAX_ENTRIES) { + // A bad cap is a violated precondition — a programmer error — so it crashes at the fault via the + // project's one assertion primitive rather than an ad-hoc `if (!x) throw` + // (docs/knowledge/assertions.md:4, docs/knowledge/error-handling.md:36). + invariant( + Number.isInteger(maxEntries) && maxEntries >= 1, + `maxEntries must be a positive integer, got ${String(maxEntries)}`, + ); + this.#maxEntries = maxEntries; + } + + /** + * Install-or-replace; never throws (CTX-8). Nothing in 4a calls this — the promotion functions are + * pure and never touch a store (CTX-17's negative half); 4c's pipeline is the first caller. + */ + install(context: ExecutionContext): void { + this.#entries.set(context.key, context); + this.#drain(); + invariant( + this.#entries.size <= this.#maxEntries, + 'context store above its cap after a drain', + ); + } + + /** + * Install only if absent; every other concurrent caller fails (CTX-8). + * + * @throws DuplicateContextKeyError when the key is already occupied. The error carries the offending + * `key` as a field — the symbol itself, not just its rendering in the message. + */ + installIfAbsent(context: ExecutionContext): void { + if (this.#entries.has(context.key)) { + throw new DuplicateContextKeyError(context.key); + } + this.#entries.set(context.key, context); + this.#drain(); + invariant( + this.#entries.size <= this.#maxEntries, + 'context store above its cap after a drain', + ); + } + + /** Absent key returns undefined, never throws (CTX-18). */ + get(key: symbol): ExecutionContext | undefined { + return this.#entries.get(key); + } + + /** + * Evicts the slot only when the current occupant IS `context` (reference identity, CTX-9). Closing an + * intermediate link already superseded by a later promotion, or an unknown/already-removed key, is a + * well-defined no-op (CTX-10, CTX-18). + */ + close(context: ExecutionContext): void { + if (this.#entries.get(context.key) === context) { + this.#entries.delete(context.key); + } + } + + /** + * Drops every entry. Not part of `§7`'s contract — it exists so a test that must observe the shared + * singleton (4c's runtime tests) can reset it. Prefer constructing an isolated `ContextStore`. + */ + clear(): void { + this.#entries.clear(); + } + + /** Entries currently tracked; at or below the cap once inserts quiesce (CTX-11, CTX-13). */ + get size(): number { + return this.#entries.size; + } + + #drain(): void { + // CTX-12 / XCUT-14: a loop, not a single check-then-evict, so an insert burst converges to the cap. + // + // DO NOT "simplify" this loop into an `if`. On this runtime the two are behaviorally identical and + // no test can tell them apart: both callers set exactly one key before draining, so the map is never + // more than one over the cap at entry and the loop never needs a second pass. The loop survives + // because CTX-12 and XCUT-14 mandate the shape for runtimes where concurrent inserts can stack + // several overshoots before any drain runs — the burst test below pins the bound, not the shape. + // + // CTX-13: victim selection is arbitrary — oldest-inserted (Map iteration order) is the cheapest + // choice, not a retention promise; callers must not rely on any particular entry surviving. + // + // No undefined-guard inside the loop: the constructor rejects maxEntries < 1, so `size > maxEntries` + // proves size >= 2 and the iterator always yields. A guard here would be unreachable code the + // coverage gate could never exercise. + for (const oldestKey of this.#entries.keys()) { + if (this.#entries.size <= this.#maxEntries) return; + this.#entries.delete(oldestKey); + } + } +} + +/** + * The one registry 4c's `Runtime.send()` installs into. Module-level mutable state, which + * `docs/knowledge/variables-and-declarations.md:22` bans — accepted here because threading a store handle + * through builder → runtime → every step would be a wide API change for no observable gain, and logged in + * the design's Deviation Ledger for Phase 10. Tests must build their own `new ContextStore()` rather than + * asserting through this one: it is shared by every test file in a `bun test` run. + * + * @internal + */ +export const contextStore = new ContextStore();