diff --git a/docs/agent-environments.md b/docs/agent-environments.md index a8fb4dfd3d..2912c2c257 100644 --- a/docs/agent-environments.md +++ b/docs/agent-environments.md @@ -871,8 +871,9 @@ names a `document` view once, at its initial document's `path`. The `version`, and the `state_evidence_id` and `types` that `data/get` reports, must match the served state, so a `data/get` result loads back unchanged. A number without an exact native value, or whose serialized spelling would not be read as exact -(an integer from 2^53 below 1e21, such as `1e20`, in any spelling), or a -`__proto__` member is refused rather than altered. +(an integer from 2^53 below 1e21, such as `1e20`, in any spelling), is refused +rather than altered. Caller data preserves all own JSON members, including +`__proto__`; unknown members of the state document's structure are refused. An invalid document is `-32602` naming the first problem by JSON pointer, as in `/rea_state/entities/3/collection` (also in `error.data.path`); a problem inside caller data (`fields`, `value`, `types`) is located at that member, so diff --git a/docs/specs/agent-environments/state-serve/spec.md b/docs/specs/agent-environments/state-serve/spec.md index 53f830ee69..3ebf83c42b 100644 --- a/docs/specs/agent-environments/state-serve/spec.md +++ b/docs/specs/agent-environments/state-serve/spec.md @@ -110,7 +110,9 @@ this increment: write tools are not served from the state. differs from the entity's id (refused at that view's `fields`), documents naming document views once at the initial path, and the optional `state_evidence_id` and `types` that `data/get` reports equal to the served - ones. Refuse numbers without an exact native value and `__proto__` members. + ones. Refuse numbers without an exact native value. Preserve all own JSON + members in caller data, including `__proto__`, and reject unknown structural + members before projecting the document's declared fields. Name the first problem by JSON pointer, never deeper than a caller-data member (`fields`, `value`, `types`), and leave the state unchanged. - Keep record citations Evidence provenance: refuse `record_ordinals` or a diff --git a/docs/specs/agent-environments/state-view/spec.md b/docs/specs/agent-environments/state-view/spec.md index c7a602e6da..5534f7c0df 100644 --- a/docs/specs/agent-environments/state-view/spec.md +++ b/docs/specs/agent-environments/state-view/spec.md @@ -133,8 +133,8 @@ later increments. - Keep an item or list plan only when it reproduces each request line's latest initial recording from the state, comparing JSON values without regard to member order; otherwise record the tool with a reason. Parse bodies only when - JSON-like, UTF-8, at most 256 KiB, exactly representable and free of - `__proto__` members. + JSON-like, UTF-8, at most 256 KiB and exactly representable. Preserve all own + JSON members, including `__proto__`, through extraction and normalization. - Mark the Evidence inferred; state unknowns for authorization, response semantics and write effects. - Charge the manifest's admission and grouping, every record line, every body diff --git a/docs/specs/agent-environments/state-writes/spec.md b/docs/specs/agent-environments/state-writes/spec.md index f45e8eb464..688ac66399 100644 --- a/docs/specs/agent-environments/state-writes/spec.md +++ b/docs/specs/agent-environments/state-writes/spec.md @@ -150,7 +150,7 @@ the exemplar. - **Request body.** The exemplar's request body must be a JSON object that could enter the state: a JSON-like media type, complete UTF-8 text within - 256 KiB, exact numbers, no `__proto__` member. Otherwise the reason is + 256 KiB and exact numbers. All own JSON member names are retained. Otherwise the reason is `body-unusable`. A `DELETE` takes no body. A create's or update's body whose only member wraps one object in a one-element array (Ghost's `{"posts":[{...}]}`) is that object, and the plan records the member as @@ -587,7 +587,7 @@ changes it atomically, so every MCP session and `data/get` see one state. - a create in another collection when the plan is tied to its own; - a document write at a path other than the one the state holds it at; - a body member or a resulting value `data/add` would refuse (a number not - exact as `data/get` writes it, a `__proto__` member, nesting past the + exact as `data/get` writes it or nesting past the limit), increments included, so `data/get` always loads back; - a create, update or document write that would take what `data/get` reports past the 32 MiB `data/add` intake limit, as a `resource_constraint` error; diff --git a/docs/web-network-captures.md b/docs/web-network-captures.md index b309312e0c..125de760ad 100644 --- a/docs/web-network-captures.md +++ b/docs/web-network-captures.md @@ -696,8 +696,9 @@ more than one query) and `rendered-oversize` (a document value whose JSON renders to more than 256 KiB). A document holds the value of its one `path`, so for such a template it says nothing about the template's other paths. A body is usable when its content type is -JSON-like, it is UTF-8 text of at most 256 KiB, every number survives native -conversion exactly, and no member is named `__proto__`. +JSON-like, it is UTF-8 text of at most 256 KiB, and every number survives native +conversion exactly. JSON member names, including `__proto__`, remain ordinary +own data members through extraction and serving. A list view also learns, from its checked lines that do not list the whole collection, what each query parameter does, and names the result in `query`. diff --git a/src/application/environment/environmentArgumentMembers.test.ts b/src/application/environment/environmentArgumentMembers.test.ts new file mode 100644 index 0000000000..20352ddcef --- /dev/null +++ b/src/application/environment/environmentArgumentMembers.test.ts @@ -0,0 +1,116 @@ +import { expect, it } from "vitest"; +import { servedFrom } from "../../../tests/fixtures/environmentStateServing.js"; +import { matchProbeRequest } from "./environmentCompareMatch.js"; +import { + selectsRecordedLine, + selectReplayExchange, +} from "./environmentReplay.js"; +import { callQuery } from "./environmentStateCallLines.js"; +import { pinnedRequest } from "./environmentStateWriteQuery.js"; + +it.each(["__proto__", "constructor", "toString"])( + "preserves an own %s probe argument and treats omission as absent", + (name) => { + const { source } = servedFrom(); + const original = source.manifest.tools[0]!; + const tool = { + ...original, + method: "GET", + path_template: "/api/books", + params: [], + query_parameter_names: [name], + }; + const manifest = { ...source.manifest, tools: [tool] }; + const call = matchProbeRequest(manifest, { + method: "GET", + path: "/api/books", + query: `?${name}=selected`, + body: undefined, + }); + expect(call).toBeDefined(); + expect(Object.hasOwn(call!.args, name)).toBe(true); + expect(call!.args[name]).toBe("selected"); + expect([...callQuery(tool, {})]).toEqual([]); + expect([...callQuery(tool, call!.args)]).toEqual([[name, "selected"]]); + const exchange = { + ...source.exchanges.values().next().value!, + path: "/api/books", + query: `?${name}=selected`, + }; + const exchanges = new Map([[exchange.ordinal, exchange]]); + expect(selectsRecordedLine(tool, exchange, {})).toBe(false); + expect(selectReplayExchange(tool, exchanges, call!.args)).toMatchObject({ + exact: true, + }); + }, +); + +it.each(["__proto__", "constructor"])( + "fills an omitted pinned %s argument as an own member", + (name) => { + const serving = servedFrom(); + const tool = { + ...serving.source.manifest.tools[0]!, + params: [], + query_parameter_names: [name], + }; + const exchange = { + ...serving.source.exchanges.values().next().value!, + query: `?${name}=selected`, + }; + const source = { + ...serving.source, + exchanges: new Map([[exchange.ordinal, exchange]]), + }; + const plan = { + ...serving.state.writes.values().next().value!, + exemplar_ordinal: exchange.ordinal, + }; + for (const args of [{}, Object.fromEntries([[name, "selected"]])]) { + const result = pinnedRequest( + { source, store: serving.store }, + { tool, plan, args }, + ); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + expect(Object.hasOwn(result.value.args, name)).toBe(true); + expect(result.value.args[name]).toBe("selected"); + } + }, +); + +it("keeps a prototype-named path argument and every own body member in a probe", () => { + const { source } = servedFrom(); + const tool = { + ...source.manifest.tools[0]!, + method: "PATCH", + path_template: "/api/books/{__proto__}", + params: [ + { + name: "__proto__", + index: 2, + rule: "numeric" as const, + basis: "inferred" as const, + observed_values: ["1"], + }, + ], + query_parameter_names: [], + }; + const body = JSON.parse( + '{"__proto__":{"selected":true},"constructor":"value"}', + ); + const call = matchProbeRequest( + { ...source.manifest, tools: [tool] }, + { + method: "PATCH", + path: "/api/books/3", + query: null, + body, + }, + ); + expect(call).toBeDefined(); + expect(Object.hasOwn(call!.args, "__proto__")).toBe(true); + expect(call!.args["__proto__"]).toBe("3"); + expect(call!.args.body).toBe(body); + expect(JSON.stringify(call!.args.body)).toBe(JSON.stringify(body)); +}); diff --git a/src/application/environment/environmentCompare.ts b/src/application/environment/environmentCompare.ts index fac79aea58..1fa4eb3cf7 100644 --- a/src/application/environment/environmentCompare.ts +++ b/src/application/environment/environmentCompare.ts @@ -1,4 +1,5 @@ import { + ownMember, decodedSegment, same, } from "../../domain/analysisView/environmentStateRules.js"; @@ -240,7 +241,7 @@ const answersCall = ( const body = probeArguments(call.tool, run.bodies).body; // A tool with no body parameter matches; one that takes a body matches only the body this call sent, including none. if (body === undefined) return true; - return same(run.recordedBodies.get(exchange.ordinal), args[body]); + return same(run.recordedBodies.get(exchange.ordinal), ownMember(args, body)); }; /** A served tool's input check: the first problem with a call's arguments, or undefined. */ @@ -348,7 +349,7 @@ const compareStep = ( }); const held = heldIds(run.store); const unknownPath = call.pathArguments.some((name) => { - const value = call.args[name]; + const value = ownMember(call.args, name); if (typeof value !== "string") return false; const id = decodedSegment(value); return !run.map.hasEnv(id) && !held.has(id); diff --git a/src/application/environment/environmentCompareMatch.ts b/src/application/environment/environmentCompareMatch.ts index 1d7704c76d..ec7e3ab5e6 100644 --- a/src/application/environment/environmentCompareMatch.ts +++ b/src/application/environment/environmentCompareMatch.ts @@ -215,12 +215,12 @@ export const matchProbeRequest = ( if (best === undefined) return undefined; const { tool } = best; const names = probeArguments(tool, bodies); - const args: Record = {}; + const args = new Map(); const pathArguments: string[] = []; for (const param of tool.params) { const name = names.path.get(param.name) ?? param.name; // The environment decodes a path id once. Passing the raw segment keeps `book%2531` from becoming `book1`. - args[name] = segments[param.index]!; + args.set(name, segments[param.index]!); pathArguments.push(name); } const ignoredQuery: string[] = []; @@ -238,12 +238,12 @@ export const matchProbeRequest = ( } } for (const [name, values] of queryValues) - args[name] = values.length === 1 ? values[0]! : values; + args.set(name, values.length === 1 ? values[0]! : values); if (names.body !== undefined && request.body !== undefined) - args[names.body] = request.body; + args.set(names.body, request.body); return { tool, - args, + args: Object.fromEntries(args), pathArguments, pagingArguments, ignoredQuery, diff --git a/src/application/environment/environmentReplay.ts b/src/application/environment/environmentReplay.ts index c7210eb547..88f8d6ed1a 100644 --- a/src/application/environment/environmentReplay.ts +++ b/src/application/environment/environmentReplay.ts @@ -1,6 +1,9 @@ import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisInputError } from "../../domain/analysisErrorCore.js"; -import { pathSegmentMatches } from "../../domain/analysisView/environmentStateRules.js"; +import { + ownMember, + pathSegmentMatches, +} from "../../domain/analysisView/environmentStateRules.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { iterateUrlQueryParameters, @@ -62,7 +65,7 @@ const scoreExchange = ( ): MatchScore => { const providedPaths: { index: number; value: string }[] = []; for (const param of tool.params) { - const value = args[pathArguments.get(param.name) ?? param.name]; + const value = ownMember(args, pathArguments.get(param.name) ?? param.name); if (typeof value === "string") providedPaths.push({ index: param.index, value }); } @@ -84,7 +87,7 @@ const scoreExchange = ( } const supplied = new Map(); for (const [name, argument] of queryArguments) { - const value = args[argument]; + const value = ownMember(args, argument); if (typeof value === "string") supplied.set(name, [value]); else if ( Array.isArray(value) && @@ -128,7 +131,8 @@ const scoreExchange = ( const occurrence = observedOccurrences.get(name) ?? 0; observedOccurrences.set(name, occurrence + 1); const argument = queryArguments.get(name); - const value = argument === undefined ? undefined : args[argument]; + const value = + argument === undefined ? undefined : ownMember(args, argument); const normalized = typeof value === "string" ? occurrence === 0 @@ -201,7 +205,7 @@ export const selectReplayExchange = ( if (best === undefined) return undefined; const suppliedQueryNames = new Set( [...argumentsMap.query].flatMap(([name, argument]) => - args[argument] === undefined ? [] : [name], + ownMember(args, argument) === undefined ? [] : [name], ), ); const queryWasSupplied = suppliedQueryNames.size > 0; @@ -266,8 +270,10 @@ export const selectsRecordedLine = ( if ( !tool.params.every( (param) => - typeof args[argumentsMap.path.get(param.name) ?? param.name] === - "string", + typeof ownMember( + args, + argumentsMap.path.get(param.name) ?? param.name, + ) === "string", ) || !scoreExchange( tool, @@ -287,7 +293,7 @@ export const selectsRecordedLine = ( } for (const [name, values] of recorded) { const argument = argumentsMap.query.get(name); - const supplied = args[argument ?? ""]; + const supplied = ownMember(args, argument ?? ""); if ( argument === undefined || (typeof supplied === "string" diff --git a/src/application/environment/environmentState.test.ts b/src/application/environment/environmentState.test.ts index 9f6ec8988e..bc58e3fb0e 100644 --- a/src/application/environment/environmentState.test.ts +++ b/src/application/environment/environmentState.test.ts @@ -29,7 +29,10 @@ import { loadEnvironmentState, prepareServedState, } from "./environmentState.js"; -import { STATE_INTAKE_LIMIT_BYTES } from "./environmentStateDocument.js"; +import { + STATE_INTAKE_LIMIT_BYTES, + parseStateDocument, +} from "./environmentStateDocument.js"; import { EnvironmentStateStore, ENVIRONMENT_COLLECTION_HISTORY_LIMIT_BYTES, @@ -679,24 +682,20 @@ describe("rea-state/v1 validation", () => { path: "rea_state/types", message: "differs from the served types", }); - const prototypeMember = (at: "entity" | "fields") => + const prototypeMember = () => firstProblem( JSON.parse( JSON.stringify(documentWith(() => undefined)).replace( - at === "entity" ? '"type":' : '"fields":{', - at === "entity" - ? '"__proto__":{"x":1},"type":' - : '"fields":{"__proto__":{"x":1},', + '"type":', + '"__proto__":{"x":1},"type":', ), ), ); - expect(prototypeMember("entity")).toEqual({ + expect(prototypeMember()).toEqual({ path: "rea_state/entities/0", message: "has a member rea-state/v1 does not define", }); - expect(prototypeMember("fields")).toMatchObject({ - path: "rea_state/entities/0/fields", - }); + expect(firstProblem([])).toMatchObject({ path: "" }); expect( firstProblem({ rea_state: { version: 2, entities: [], documents: [] } }), @@ -736,3 +735,70 @@ describe("wide state value validation", () => { expect(retainedObject).toEqual(wideObject); }); }); + +it("retains arbitrary dictionary keys and owned JSON references at state intake", () => { + const dictionary = JSON.parse( + '{"__proto__":{"constructor":{"prototype":"kept"}},"constructor":7,"prototype":8}', + ); + const document = documentWith((value) => { + const entity = value.rea_state.entities[0]!; + entity.fields = { ...(entity.fields as object), ...dictionary }; + }); + const entity = document.rea_state.entities[0]!; + const fields = entity.fields; + const owned = parseStateDocument(state, document, true); + expect(owned.ok).toBe(true); + if (!owned.ok) throw owned.error; + expect(owned.value.entities[0]?.fields).toBe(fields); + const copied = parseStateDocument(state, document); + expect(copied.ok).toBe(true); + if (!copied.ok) throw copied.error; + expect(copied.value.entities[0]?.fields).not.toBe(fields); + expect(copied.value.entities[0]?.fields).toEqual(fields); + expect(Object.hasOwn(copied.value.entities[0]!.fields, "__proto__")).toBe( + true, + ); + const store = new EnvironmentStateStore(state); + expect(store.replace(document).ok).toBe(true); + const snapshot = JSON.stringify(store.snapshot()); + expect(store.replace(JSON.parse(snapshot)).ok).toBe(true); + expect(JSON.stringify(store.snapshot())).toBe(snapshot); +}); + +it.each(["__proto__", "constructor", "prototype"])( + "refuses unknown structural member %s before projection", + (key) => { + const document = documentWith(() => undefined); + const structures = [ + document, + document.rea_state, + document.rea_state.entities[0]!, + ]; + const view = { tool: "fixture", fields: {} }; + document.rea_state.entities[0]!.view_fields = [view]; + const held = { tool: "fixture", path: "/fixture", value: {} }; + document.rea_state.documents = [held]; + structures.push(view, held); + for (const structure of structures) { + Object.defineProperty(structure, key, { + value: 1, + configurable: true, + enumerable: true, + }); + expect(firstProblem(document).message).toBe( + "has a member rea-state/v1 does not define", + ); + Reflect.deleteProperty(structure, key); + } + }, +); + +it("keeps missing fields distinct from invalid JSON field contents", () => { + const document = documentWith((value) => { + Reflect.deleteProperty(value.rea_state.entities[0]!, "fields"); + }); + expect(firstProblem(document)).toEqual({ + path: "rea_state/entities/0/fields", + message: "is required", + }); +}); diff --git a/src/application/environment/environmentStateAnswer.ts b/src/application/environment/environmentStateAnswer.ts index 27155071ca..4a4b329349 100644 --- a/src/application/environment/environmentStateAnswer.ts +++ b/src/application/environment/environmentStateAnswer.ts @@ -1,6 +1,7 @@ import { hasRepeatedQueryArgument } from "./environmentTools.js"; import { stateBodyJson } from "../../domain/analysisView/EnvironmentStateAccumulator.js"; import { + ownMember, collectionMatch, collectionPathOf, decodedSegment, @@ -439,7 +440,7 @@ const listSelection = ( } const idField = store.type(view.type)?.id_field ?? ""; const members = line.recorded.flatMap((item) => { - const id = scalarText(item[idField]); + const id = scalarText(ownMember(item, idField)); const entity = id === undefined ? undefined : store.entity(view.type, collection, id); return entity === undefined ? [] : [entity]; diff --git a/src/application/environment/environmentStateBulkAnswer.ts b/src/application/environment/environmentStateBulkAnswer.ts index 8ce6d25434..aa44a69358 100644 --- a/src/application/environment/environmentStateBulkAnswer.ts +++ b/src/application/environment/environmentStateBulkAnswer.ts @@ -9,9 +9,10 @@ import { writtenBulkResponse, } from "../../domain/analysisView/environmentStateWriteApply.js"; import type { FieldRule } from "../../domain/analysisView/environmentStateWriteRules.js"; -import type { - JsonObject, - StateEntity, +import { + ownMember, + type JsonObject, + type StateEntity, } from "../../domain/analysisView/environmentStateRules.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import type { JsonValue } from "../../domain/jsonValue.js"; @@ -57,7 +58,8 @@ const namedTargets = ( const { address } = plan; const type = plan.target ?? ""; const name = environmentToolArguments(tool, true).body; - const body = name === undefined ? undefined : (args[name] as JsonValue); + const body = + name === undefined ? undefined : (ownMember(args, name) as JsonValue); if (body !== undefined && !isLoadableValue(body)) return err(unloadable(tool, "The body")); const named = diff --git a/src/application/environment/environmentStateCallLines.ts b/src/application/environment/environmentStateCallLines.ts index b661031076..e6c93b4f14 100644 --- a/src/application/environment/environmentStateCallLines.ts +++ b/src/application/environment/environmentStateCallLines.ts @@ -1,4 +1,5 @@ import { + ownMember, allLines, collectionPathOf, collectionMatch, @@ -35,7 +36,7 @@ export const callSegments = ( segments.push({ text, supplied: false }); continue; } - const value = args[names.get(param.name) ?? param.name]; + const value = ownMember(args, names.get(param.name) ?? param.name); if (typeof value !== "string") return undefined; segments.push({ text: value, supplied: true }); } @@ -74,7 +75,9 @@ export const unfilteredLine = ( const segments = callSegments(tool, args); if ( segments === undefined || - [...query.values()].some((argument) => args[argument] !== undefined) + [...query.values()].some( + (argument) => ownMember(args, argument) !== undefined, + ) ) return undefined; const lines = new Map(); @@ -98,7 +101,7 @@ export const callQuery = ( new Map( [...environmentToolArguments(tool).query].flatMap( ([name, argument]): [string, string][] => { - const value = args[argument]; + const value = ownMember(args, argument); return value === undefined ? [] : [[name, String(value)]]; }, ), diff --git a/src/application/environment/environmentStateDocument.ts b/src/application/environment/environmentStateDocument.ts index 985b138fa5..85febafb2e 100644 --- a/src/application/environment/environmentStateDocument.ts +++ b/src/application/environment/environmentStateDocument.ts @@ -20,6 +20,8 @@ import { canonicalJson } from "../../domain/comparisonSemantics.js"; import { parseExactJson } from "../../domain/exactJsonNumber.js"; import { projectInputIssues } from "../../domain/inputIssueProjection.js"; import { + jsonObjectSchema, + ownedJsonObjectSchema, jsonValueSchema, jsonValueValidationIssue, ownedJsonValueSchema, @@ -45,30 +47,58 @@ export interface StateContent { readonly documents: readonly HeldDocument[]; } -const stateDocumentSchemaFor = (jsonValue: z.ZodType) => { - const fieldsSchema = z.record(z.string(), jsonValue); +/** Check source-owned structural keys before Zod projects the declared fields. */ +const strictStateObject = (shape: Shape) => { + const schema = z.strictObject(shape); + return z + .unknown() + .superRefine((value, context) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) + return; + if (types.isProxy(value)) { + context.addIssue({ + code: "custom", + message: "Expected an ordinary state object", + }); + return; + } + const keys = Object.getOwnPropertyNames(value).filter( + (key) => !Object.hasOwn(schema.shape, key), + ); + if (keys.length > 0) + context.addIssue({ code: "unrecognized_keys", keys }); + }) + .pipe(schema); +}; + +const stateDocumentSchemaFor = ( + jsonValue: z.ZodType, + fieldsSchema: z.ZodType, +) => { const ordinalSchema = z.number().int().nonnegative(); - return z.strictObject({ - rea_state: z.strictObject({ + return strictStateObject({ + rea_state: strictStateObject({ version: z.literal(1), // `data/get` reports these; accepting them back lets its output be loaded unchanged. state_evidence_id: z.string().optional(), types: z.array(jsonValue).optional(), entities: z.array( - z.strictObject({ + strictStateObject({ type: z.string(), collection: z.string(), id: z.string(), fields: fieldsSchema, view_fields: z - .array(z.strictObject({ tool: z.string(), fields: fieldsSchema })) + .array( + strictStateObject({ tool: z.string(), fields: fieldsSchema }), + ) .optional(), record_ordinals: z.array(ordinalSchema).optional(), }), ), documents: z.array( - z.strictObject({ + strictStateObject({ tool: z.string(), path: z.string(), value: jsonValue, @@ -79,8 +109,14 @@ const stateDocumentSchemaFor = (jsonValue: z.ZodType) => { }); }; -const stateDocumentSchema = stateDocumentSchemaFor(jsonValueSchema); -const ownedStateDocumentSchema = stateDocumentSchemaFor(ownedJsonValueSchema); +const stateDocumentSchema = stateDocumentSchemaFor( + jsonValueSchema, + jsonObjectSchema, +); +const ownedStateDocumentSchema = stateDocumentSchemaFor( + ownedJsonValueSchema, + ownedJsonObjectSchema, +); type ParsedDocument = z.output["rea_state"]; type ParsedEntity = ParsedDocument["entities"][number]; @@ -116,99 +152,14 @@ const memberProblem = (path: Path): AnalysisInputIssue => { ); }; -/** The path of the first `__proto__` member in document order; schema parsing would drop such a member rather than refuse it. */ -const prototypeMemberPath = (root: unknown): Path | undefined => { - interface Cursor { - readonly value: unknown; - readonly parent: Cursor | undefined; - readonly key: string | number; - readonly entries?: Generator; - index: number; - } - const pathOf = (node: Cursor): (string | number)[] => { - const path: (string | number)[] = []; - for (let at: Cursor = node; at.parent !== undefined; at = at.parent) - path.push(at.key); - return path.reverse(); - }; - function* ownKeys(value: Record): Generator { - // `for...in` is lazy. The ownership check keeps inherited enumerable - // properties out, matching Object.entries without allocating all entries. - for (const key in value) if (Object.hasOwn(value, key)) yield key; - } - const cursor = ( - value: unknown, - parent: Cursor | undefined, - key: string | number, - ): Cursor => ({ - value, - parent, - key, - index: 0, - ...(!Array.isArray(value) && typeof value === "object" && value !== null - ? { entries: ownKeys(value as Record) } - : {}), - }); - const pending: Cursor[] = [cursor(root, undefined, "")]; - while (pending.length > 0) { - const node = pending[pending.length - 1]!; - const { value } = node; - if (typeof value !== "object" || value === null) { - pending.pop(); - continue; - } - // The canonical JSON validator reports proxies without invoking their traps. - if (types.isProxy(value)) { - pending.pop(); - continue; - } - const array = Array.isArray(value); - if (!array && Object.hasOwn(value, "__proto__")) - return [...pathOf(node), "__proto__"]; - if (array) { - if (node.index >= value.length) { - pending.pop(); - continue; - } - const index = node.index++; - const descriptor = Object.getOwnPropertyDescriptor(value, index); - if ( - descriptor?.enumerable !== true || - !Object.hasOwn(descriptor, "value") - ) - continue; - const child: unknown = descriptor.value; - if (typeof child === "object" && child !== null) - pending.push(cursor(child, node, index)); - continue; - } - const next = node.entries?.next(); - if (next === undefined || next.done) { - pending.pop(); - continue; - } - const key = next.value; - const descriptor = Object.getOwnPropertyDescriptor(value, key); - if (descriptor?.enumerable !== true || !Object.hasOwn(descriptor, "value")) - continue; - const child: unknown = descriptor.value; - if (typeof child === "object" && child !== null) - pending.push(cursor(child, node, key)); - } - return undefined; -}; - -/** Whether `data/add` accepts a value as a `fields` member or a document's `value`: bounded nesting, no `__proto__` member, and numbers exact as `data/get` writes them. */ +/** Whether `data/add` accepts a value as a `fields` member or a document's `value`: bounded nesting and numbers exact as `data/get` writes them. */ export const isLoadableValue = (value: JsonValue): boolean => jsonValueValidationIssue(value) === undefined && - prototypeMemberPath(value) === undefined && parseExactJson(JSON.stringify(value)) !== undefined; -/** The first member of an object `data/add` would refuse as `fields`, if any: a `__proto__` member, or one whose value does not load. */ +/** The first member of an object `data/add` would refuse as `fields`, if any: one whose value does not load. */ export const unloadableMember = (object: JsonObject): string | undefined => - Object.keys(object).find( - (member) => member === "__proto__" || !isLoadableValue(object[member]!), - ); + Object.keys(object).find((member) => !isLoadableValue(object[member]!)); /** The first schema problem, located without naming the caller's data. */ const schemaProblem = ( @@ -224,6 +175,15 @@ const schemaProblem = ( first.reason === "unknown_argument" ) return memberProblem(first.path); + if ( + content >= 0 && + first.path[content] === "fields" && + first.reason === "invalid_value" + ) + return problem( + first.path.slice(0, content + 1), + "holds a value that is not exactly representable JSON", + ); if (first.reason === "missing_argument") return { ...first, message: "is required" }; return { @@ -443,19 +403,15 @@ export const parseStateDocument = ( value: unknown, owned = false, ): Result => { - const prototype = prototypeMemberPath(value); const parsed = ( owned ? ownedStateDocumentSchema : stateDocumentSchema ).safeParse(value); const initial = new Map( state.entities.map((entity) => [entityKey(entity), entity]), ); - const issue = - prototype !== undefined - ? memberProblem(prototype) - : parsed.success - ? contentProblem(state, parsed.data.rea_state, initial) - : schemaProblem(parsed.error.issues, value); + const issue = parsed.success + ? contentProblem(state, parsed.data.rea_state, initial) + : schemaProblem(parsed.error.issues, value); if (issue !== undefined || !parsed.success) return err( new AnalysisInputError( diff --git a/src/application/environment/environmentStateWriteAnswer.ts b/src/application/environment/environmentStateWriteAnswer.ts index 838e679590..0eb24a170a 100644 --- a/src/application/environment/environmentStateWriteAnswer.ts +++ b/src/application/environment/environmentStateWriteAnswer.ts @@ -38,6 +38,7 @@ import { spelledLike, } from "../../domain/analysisView/environmentStateWriteValues.js"; import { + ownMember, collectionMatch, decodedSegment, isObject, @@ -97,7 +98,7 @@ export const pathValues = ( .split("/"); const values = new Map(); for (const param of tool.params) { - const value = args[names.get(param.name) ?? param.name]; + const value = ownMember(args, names.get(param.name) ?? param.name); const segment = template[param.index] ?? ""; if (typeof value === "string") values.set(segment.slice(1, -1), value); } @@ -121,7 +122,7 @@ const callBody = ({ plan, }: WriteRequest): Result => { const name = environmentToolArguments(tool).body; - const sent = name === undefined ? undefined : args[name]; + const sent = name === undefined ? undefined : ownMember(args, name); if (sent === undefined) return ok(undefined); const envelope = plan.body?.envelope ?? ""; const value = unenveloped(sent as JsonValue, plan.body?.envelope); @@ -240,7 +241,7 @@ export const generator = ( make: (attempt: number) => JsonValue, ): Result => { const held = new Set( - ofType.map((entity) => scalarText(entity.fields[field])), + ofType.map((entity) => scalarText(ownMember(entity.fields, field))), ); for (let attempt = 0; attempt < GENERATION_ATTEMPTS; attempt += 1) { const value = make(attempt); @@ -254,7 +255,7 @@ export const generator = ( target: GeneratedTarget, rule: GeneratedRule, ): Result => { - const exemplar = target.observed[rule.field] ?? ""; + const exemplar = ownMember(target.observed, rule.field) ?? ""; const seed = (attempt: number) => `${store.state.evidenceId}:${request.tool.name}:${rule.field}:${String(write.count)}:${String(attempt)}`; switch (rule.rule) { @@ -268,7 +269,7 @@ export const generator = ( ); return rule.rule === "nested-clock" ? nestedClockValue( - (target.current ?? target.observed)[rule.field], + ownMember(target.current ?? target.observed, rule.field), rule, value, ) @@ -299,7 +300,7 @@ export const generator = ( case "renewed": return ok( renewUuids( - target.current?.[rule.field] ?? null, + ownMember(target.current, rule.field) ?? null, rule.pointers, renewed, ), @@ -384,7 +385,7 @@ const pathArguments = ( const names = environmentToolArguments(tool).path; return new Map( tool.params.flatMap((param) => { - const value = args[names.get(param.name) ?? param.name]; + const value = ownMember(args, names.get(param.name) ?? param.name); return typeof value === "string" ? [[param.index, value] as const] : []; }), ); @@ -497,7 +498,7 @@ const answerCreate = ( if (!fields.ok) return err(refused(tool, fields.error)); const field = unloadableMember(fields.value); if (field !== undefined) return err(unloadable(tool, `Field ${field}`)); - const id = scalarText(fields.value[idField]); + const id = scalarText(ownMember(fields.value, idField)); if ( id === undefined || serving.store.entity(type, collection, id) !== undefined diff --git a/src/application/environment/environmentStateWriteQuery.ts b/src/application/environment/environmentStateWriteQuery.ts index 23e1925d3b..ede0857e4c 100644 --- a/src/application/environment/environmentStateWriteQuery.ts +++ b/src/application/environment/environmentStateWriteQuery.ts @@ -28,8 +28,10 @@ export const pinnedRequest = ( ); if (!query.ok) return err(refused(tool, `Tool ${tool.name} ${query.error}`)); const names = environmentToolArguments(tool).query; - const filled = { ...args }; - for (const [name, value] of query.value) - filled[names.get(name) ?? name] ??= value; - return ok({ ...request, args: filled }); + const filled = new Map(Object.entries(args)); + for (const [name, value] of query.value) { + const argument = names.get(name) ?? name; + if (filled.get(argument) == null) filled.set(argument, value); + } + return ok({ ...request, args: Object.fromEntries(filled) }); }; diff --git a/src/application/environment/environmentStateWriteRefusals.test.ts b/src/application/environment/environmentStateWriteRefusals.test.ts index c989c6777c..4c2d36f7b1 100644 --- a/src/application/environment/environmentStateWriteRefusals.test.ts +++ b/src/application/environment/environmentStateWriteRefusals.test.ts @@ -120,20 +120,27 @@ describe("served document writes", () => { }); describe("values the state can load back", () => { + it("preserves nested prototype-named data through writes and reload", () => { + const settings = servedFrom(SETTINGS_ROWS); + const meta = JSON.parse( + '{"__proto__":{"n":1},"constructor":2,"prototype":3}', + ); + expect( + settings.answer(PATCH_SETTINGS, { id: "1", body: { meta } }).body, + ).toEqual({ theme: "dark", meta }); + expect(settings.answer(READ_SETTINGS, { id: "1" }).body).toEqual({ + theme: "dark", + meta, + }); + expectReloadsUnchanged(settings); + }); + it("refuses a body value data/add would refuse, whatever member carries it", () => { const settings = servedFrom(SETTINGS_ROWS); const unexact = "Member meta would hold a value that is not exactly representable JSON"; expectRefusedUnchanged(settings, [ [PATCH_SETTINGS, { id: "1", body: { meta: { n: 1e20 } } }, unexact], - [ - PATCH_SETTINGS, - { - id: "1", - body: JSON.parse('{"meta":{"__proto__":{"n":1}}}') as JsonValue, - }, - unexact, - ], [ PATCH_SETTINGS, { id: "1", body: { meta: { n: nested(MAX_JSON_DEPTH) } } }, diff --git a/src/application/environment/environmentTools.ts b/src/application/environment/environmentTools.ts index 1cda2c6e89..f334d9b503 100644 --- a/src/application/environment/environmentTools.ts +++ b/src/application/environment/environmentTools.ts @@ -1,4 +1,5 @@ import { + ownMember, isObject, isQueryPost, listItems, @@ -318,7 +319,7 @@ export const hasRepeatedQueryArgument = ( args: Readonly>, ): boolean => { for (const argument of environmentToolArguments(tool).query.values()) { - const value = args[argument]; + const value = ownMember(args, argument); if (Array.isArray(value) && value.length > 1) return true; } return false; diff --git a/src/domain/analysisView/EnvironmentStateAccumulator.ts b/src/domain/analysisView/EnvironmentStateAccumulator.ts index 98f3d56916..a8dd690bf8 100644 --- a/src/domain/analysisView/EnvironmentStateAccumulator.ts +++ b/src/domain/analysisView/EnvironmentStateAccumulator.ts @@ -22,7 +22,6 @@ import { EnvironmentStateExtraction } from "./EnvironmentStateExtraction.js"; import type { TemplateRule } from "./environmentManifestRules.js"; import { deeperThan, - hasPrototypeKey, isStateWrite, STATE_BODY_LIMIT_BYTES, type ParsedBody, @@ -68,7 +67,7 @@ type StateFields = Pick< */ const STATE_NESTING_DEPTH = 16; -/** A recorded response body as state JSON: a JSON-like media type, complete UTF-8 text within the state body limit, exact numbers and no `__proto__` member; otherwise undefined. */ +/** A recorded response body as state JSON: a JSON-like media type, complete UTF-8 text within the state body limit, exact numbers; otherwise undefined. */ export const stateBodyJson = ( contentType: string | null, body: RecordedResponseBody, @@ -84,7 +83,6 @@ export const stateBodyJson = ( return undefined; const value = parseExactJson(body.bodyText); return value === undefined || - hasPrototypeKey(value) || deeperThan(value, MAX_JSON_DEPTH - STATE_NESTING_DEPTH) ? undefined : value; diff --git a/src/domain/analysisView/EnvironmentStateExtraction.ts b/src/domain/analysisView/EnvironmentStateExtraction.ts index c423cd5db1..18e7bbbd1e 100644 --- a/src/domain/analysisView/EnvironmentStateExtraction.ts +++ b/src/domain/analysisView/EnvironmentStateExtraction.ts @@ -5,10 +5,10 @@ import { compositeKey, } from "../unicodeCodePointOrder.js"; import { + ownMember, apiBaseLengths, at, collectionPathOf, - hasPrototypeKey, identifies, IDENTITY_NAMES, identityCandidates, @@ -675,7 +675,8 @@ export class EnvironmentStateExtraction { const field = this.#identity.get(plan.type)!.field; let changed = previous.ids.size !== objects.length; for (const object of objects) - if (!previous.ids.delete(scalarText(object[field])!)) changed = true; + if (!previous.ids.delete(scalarText(ownMember(object, field))!)) + changed = true; if (previous.ids.size > 0) changed = true; if ( earlier !== undefined && @@ -691,7 +692,8 @@ export class EnvironmentStateExtraction { }); } previous.ids.clear(); - for (const object of objects) previous.ids.add(scalarText(object[field])!); + for (const object of objects) + previous.ids.add(scalarText(ownMember(object, field))!); previous.record = record; } @@ -703,7 +705,7 @@ export class EnvironmentStateExtraction { index: number, ): void { const field = this.#identity.get(plan.type)!.field; - const id = scalarText(object[field]); + const id = scalarText(ownMember(object, field)); if (id === undefined) return; const collection = collectionPathOf(record, plan.item); const key = entityKey(plan.type, collection, id); diff --git a/src/domain/analysisView/environmentStateGate.ts b/src/domain/analysisView/environmentStateGate.ts index 53321d7464..f1209064ba 100644 --- a/src/domain/analysisView/environmentStateGate.ts +++ b/src/domain/analysisView/environmentStateGate.ts @@ -2,6 +2,7 @@ import { identifierWords } from "./environmentStateWords.js"; import type { JsonValue } from "../jsonValue.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; import { + ownMember, at, collectionPathOf, isObject, @@ -246,7 +247,7 @@ const listQuery = ( ordinal: record.ordinal, values, choices, - listed: items.map((item) => scalarText(item[field]) ?? ""), + listed: items.map((item) => scalarText(ownMember(item, field)) ?? ""), entities: context.inCollection( plan.type, collectionPathOf(record, false), @@ -317,7 +318,7 @@ const listReproduces = ( body === undefined ? undefined : listItems(body, plan.pointer); if (body === undefined || items === undefined) return false; const observed = new Map( - items.map((item) => [scalarText(item[field]) ?? "", item]), + items.map((item) => [scalarText(ownMember(item, field)) ?? "", item]), ); const computed = view.query?.record_ordinals.includes(record.ordinal) ? evaluateQuery( @@ -333,7 +334,7 @@ const listReproduces = ( const entity = context.entity( plan.type, collection, - scalarText(item[field]) ?? "", + scalarText(ownMember(item, field)) ?? "", ); return entity === undefined ? [] : [entity]; })); diff --git a/src/domain/analysisView/environmentStateListFacts.ts b/src/domain/analysisView/environmentStateListFacts.ts index efbaf96824..ecd7bfeb84 100644 --- a/src/domain/analysisView/environmentStateListFacts.ts +++ b/src/domain/analysisView/environmentStateListFacts.ts @@ -8,6 +8,7 @@ import { mintedFor, } from "./environmentStateResponseMembers.js"; import { + ownMember, collectionPathOf, isObject, listItems, @@ -62,7 +63,7 @@ export const listFacts = ( Object.keys(parent).some((name) => continues(name, parent[name])) ) return; - const ids = items.map((item) => scalarText(item[field]) ?? ""); + const ids = items.map((item) => scalarText(ownMember(item, field)) ?? ""); const expected = context .inCollection(plan.type, collectionPathOf(record, false)) .map((entity) => entity.id); diff --git a/src/domain/analysisView/environmentStateListQuery.ts b/src/domain/analysisView/environmentStateListQuery.ts index d7c0cbac87..387877a1a8 100644 --- a/src/domain/analysisView/environmentStateListQuery.ts +++ b/src/domain/analysisView/environmentStateListQuery.ts @@ -3,6 +3,7 @@ import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js import { literalSubstringPredicate } from "../analysisSearch.js"; import { directionOf, orderRole } from "./environmentStateOrderParameters.js"; import { + ownMember, isObject, recordedItemCount, formatItemCount, @@ -81,7 +82,8 @@ const textOf = (value: JsonValue | undefined): string | undefined => /** Whether a field holds a value: equal as text, or in an array directly or as an object's id, slug or name. */ const holds = (field: JsonValue | undefined, value: string): boolean => { const names = (item: JsonValue | undefined) => - isObject(item) && IDENTIFYING.some((key) => textOf(item[key]) === value); + isObject(item) && + IDENTIFYING.some((key) => textOf(ownMember(item, key)) === value); return Array.isArray(field) ? field.some((item) => textOf(item) === value || names(item)) : textOf(field) === value || names(field); @@ -95,7 +97,7 @@ const contains = ( const matches = literalSubstringPredicate(term, false); return (entity) => fields.some((field) => { - const value = entity.fields[field]; + const value = ownMember(entity.fields, field); return typeof value === "string" && matches(value); }); }; @@ -181,7 +183,7 @@ const readCall = ( if (PAGING_ROLES.has(parameter.role)) taken.add(parameter.role); if (parameter.role === "filter") call.selections.push((entity) => - holds(entity.fields[parameter.field], value), + holds(ownMember(entity.fields, parameter.field), value), ); else if (parameter.role === "search") { if (!plainSearch(value)) return undefined; @@ -234,8 +236,8 @@ const sorted = ( (left, right) => sign * ascending( - left.entity.fields[key.field], - right.entity.fields[key.field], + ownMember(left.entity.fields, key.field), + ownMember(right.entity.fields, key.field), ) || left.index - right.index, ) .map(({ entity }) => entity); @@ -245,7 +247,10 @@ const sorted = ( ) => left !== undefined && right !== undefined && - ascending(left.fields[key.field], right.fields[key.field]) === 0; + ascending( + ownMember(left.fields, key.field), + ownMember(right.fields, key.field), + ) === 0; return { entities: ordered, positional: ordered.filter( @@ -331,7 +336,9 @@ const filterFields = ( items.length === 0 ? [] : Object.keys(items[0]!) - .filter((field) => items.every((item) => holds(item[field], value))) + .filter((field) => + items.every((item) => holds(ownMember(item, field), value)), + ) .sort( (left, right) => Number(normalized(right) === normalized(name)) - @@ -428,7 +435,9 @@ const withoutIdleFilters = ( Object.entries(chosen).map(([name, role]) => { const value = line.values.get(name)!; return role.role === "filter" && - line.entities.every((entity) => holds(entity.fields[role.field], value)) + line.entities.every((entity) => + holds(ownMember(entity.fields, role.field), value), + ) ? [name, { role: "inert", values: [value] }] : [name, role]; }), @@ -547,7 +556,9 @@ const trustedRoles = ( if (role.role !== "filter" || namedLike(role.field, name)) return true; const values = new Set( explained - .filter((entry) => entry.parameters[name]?.role === "filter") + .filter( + (entry) => ownMember(entry.parameters, name)?.role === "filter", + ) .map((entry) => entry.line.values.get(name)), ); return values.size >= 2; diff --git a/src/domain/analysisView/environmentStateRules.ts b/src/domain/analysisView/environmentStateRules.ts index a4d1c0c342..e4746e88ad 100644 --- a/src/domain/analysisView/environmentStateRules.ts +++ b/src/domain/analysisView/environmentStateRules.ts @@ -33,7 +33,7 @@ export interface StateToolFacts { readonly record_ordinals: readonly number[]; } -/** A record's response body as JSON, or unusable when absent, cut, not JSON, not exactly representable or carrying a `__proto__` member. */ +/** A record's response body as JSON, or unusable when absent, cut, not JSON, not exactly representable. */ export type ParsedBody = | { readonly kind: "json"; readonly value: JsonValue } | { readonly kind: "unusable" }; @@ -265,6 +265,29 @@ export const scalarText = (value: JsonValue | undefined): string | undefined => : typeof value === "number" ? String(value) : undefined; +/** A JSON dictionary lookup observes only its own members, preserving absent fields. */ +export const ownMember = ( + object: Readonly> | undefined, + member: string, +): Value | undefined => + object !== undefined && Object.hasOwn(object, member) + ? object[member] + : undefined; + +/** Write one JSON member without interpreting its name as an inherited setter. */ +export const setOwnMember = ( + object: JsonObject, + member: string, + value: JsonValue, +): void => { + Object.defineProperty(object, member, { + value, + enumerable: true, + configurable: true, + writable: true, + }); +}; + /** Equal JSON values; object member order carries no meaning. */ export const same = ( left: JsonValue | undefined, @@ -463,15 +486,6 @@ export const deeperThan = (value: JsonValue, limit: number): boolean => { return false; }; -/** JSON parsers keep a `__proto__` member that plain object construction would turn into a prototype. */ -export const hasPrototypeKey = (value: JsonValue): boolean => - Array.isArray(value) - ? value.some(hasPrototypeKey) - : isObject(value) && - Object.entries(value).some( - ([name, item]) => name === "__proto__" || hasPrototypeKey(item), - ); - export const pointerTo = (name: string): string => `/${name.replaceAll("~", "~0").replaceAll("/", "~1")}`; /** The unescaped reference tokens of a pointer. */ @@ -487,7 +501,7 @@ const memberOf = ( body: JsonValue | undefined, name: string, ): JsonValue | undefined => - isObject(body) && Object.hasOwn(body, name) ? body[name] : undefined; + isObject(body) ? ownMember(body, name) : undefined; /** The element of a one-element array. */ const soleElement = (value: JsonValue | undefined): JsonValue | undefined => @@ -570,7 +584,7 @@ export const identifies = ( // Stop at the first missing or repeated value, so a sparse list costs one read per field. const seen = new Set(); for (const item of items) { - const value = scalarText(item[name]); + const value = scalarText(ownMember(item, name)); if (value === undefined || seen.has(value)) return false; seen.add(value); } @@ -603,7 +617,9 @@ export const locateList = ( if (Array.isArray(body)) return body.every(isObject) ? "" : undefined; if ( !isObject(body) || - IDENTITY_NAMES.some((name) => scalarText(body[name]) !== undefined) + IDENTITY_NAMES.some( + (name) => scalarText(ownMember(body, name)) !== undefined, + ) ) return undefined; const arrays = Object.entries(body).filter( diff --git a/src/domain/analysisView/environmentStateView.test.ts b/src/domain/analysisView/environmentStateView.test.ts index 970d429001..a10be1cc3c 100644 --- a/src/domain/analysisView/environmentStateView.test.ts +++ b/src/domain/analysisView/environmentStateView.test.ts @@ -1790,3 +1790,32 @@ it("validates every own render-metadata dictionary entry through the public sche expect(parse({ kind: "epoch", unit: "invalid" }, ["id"]).success).toBe(false); expect(parse(clock, [1]).success).toBe(false); }); + +it("preserves own prototype-named entity and document fields through capture projection and public normalization", () => { + const first = JSON.parse( + '{"id":1,"__proto__":{"label":"one"},"constructor":"first","meta":{"__proto__":"nested"}}', + ) as JsonObject; + const second = JSON.parse( + '{"id":2,"__proto__":{"label":"two"},"constructor":"second","meta":{"__proto__":"nested"}}', + ) as JsonObject; + const document = JSON.parse('{"__proto__":{"theme":"dark"}}') as JsonObject; + const projected = stateOf( + captureOf([ + ["GET", "/api/items", [first, second]], + ["GET", "/api/items/1", first], + ["GET", "/api/settings", document], + ]), + ); + const state = analysisViewResultSchema.parse( + JSON.parse(JSON.stringify(projected)), + ); + if (state.kind !== "environment-state") throw new Error("Expected state"); + const entity = state.entities.find((entry) => entry.id === "1")!; + expect(JSON.stringify(entity.fields)).toBe(JSON.stringify(first)); + expect( + Object.getOwnPropertyDescriptor(entity.fields, "__proto__")?.value, + ).toEqual({ label: "one" }); + expect(state.documents).toContainEqual( + expect.objectContaining({ value: document }), + ); +}); diff --git a/src/domain/analysisView/environmentStateView.ts b/src/domain/analysisView/environmentStateView.ts index cf1b4e09ba..cfb4a95cbf 100644 --- a/src/domain/analysisView/environmentStateView.ts +++ b/src/domain/analysisView/environmentStateView.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { AnalysisError } from "../analysisErrorBase.js"; -import { jsonValueSchema } from "../jsonValue.js"; +import { jsonObjectSchema, jsonValueSchema } from "../jsonValue.js"; import { jsonRecordSchema } from "../jsonRecord.js"; import { ok, type Result } from "../result.js"; import type { @@ -23,7 +23,6 @@ export const environmentStateViewRequestSchema = z.strictObject({ ), }); -const jsonObjectFieldsSchema = z.record(z.string(), jsonValueSchema); const queryParameterSchema = z.discriminatedUnion("role", [ z.strictObject({ role: z.literal("filter"), @@ -471,9 +470,9 @@ export const environmentStateFields = { type: z.string(), collection: z.string(), id: z.string(), - fields: jsonObjectFieldsSchema, + fields: jsonObjectSchema, view_fields: z.array( - z.strictObject({ tool: z.string(), fields: jsonObjectFieldsSchema }), + z.strictObject({ tool: z.string(), fields: jsonObjectSchema }), ), record_ordinals: z.array(z.number().int().nonnegative()), }), diff --git a/src/domain/analysisView/environmentStateViewSpellings.ts b/src/domain/analysisView/environmentStateViewSpellings.ts index 488dc216c8..3c85ea01f0 100644 --- a/src/domain/analysisView/environmentStateViewSpellings.ts +++ b/src/domain/analysisView/environmentStateViewSpellings.ts @@ -1,5 +1,10 @@ import type { JsonValue } from "../jsonValue.js"; -import { isObject, same, type JsonObject } from "./environmentStateRules.js"; +import { + ownMember, + isObject, + same, + type JsonObject, +} from "./environmentStateRules.js"; import { readClock, type ClockFormat } from "./environmentStateWriteValues.js"; import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js"; @@ -82,7 +87,7 @@ export class ClockSpellings { note(stored: JsonObject, shown: JsonObject): void { for (const [field, value] of Object.entries(shown)) { - if (isObject(value)) this.#shape(field, value, stored[field]); + if (isObject(value)) this.#shape(field, value, ownMember(stored, field)); const now = readClock(value); if (now === undefined) continue; const was = Object.hasOwn(stored, field) diff --git a/src/domain/analysisView/environmentStateWriteAddress.ts b/src/domain/analysisView/environmentStateWriteAddress.ts index ae5a98cff5..e50fa830fe 100644 --- a/src/domain/analysisView/environmentStateWriteAddress.ts +++ b/src/domain/analysisView/environmentStateWriteAddress.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { + ownMember, at, requestJson, isObject, @@ -156,7 +157,8 @@ export const addressOf = ( ) return { by: "ids", member, kind }; return value.every( - (entry) => isObject(entry) && scalarText(entry[idField]) !== undefined, + (entry) => + isObject(entry) && scalarText(ownMember(entry, idField)) !== undefined, ) ? { by: "elements", member } : undefined; @@ -235,7 +237,9 @@ export const namedEntities = ( ) return undefined; const entries = ( - address.member === null ? body : (body as JsonObject)[address.member] + address.member === null + ? body + : ownMember(body as JsonObject, address.member) ) as JsonValue[]; const shared = address.member === null || !isObject(body) @@ -246,7 +250,10 @@ export const namedEntities = ( const named = entries.map((entry) => address.by === "ids" ? { id: scalarText(entry)!, body: shared } - : { id: scalarText((entry as JsonObject)[idField])!, body: entry }, + : { + id: scalarText(ownMember(entry as JsonObject, idField))!, + body: entry, + }, ) as NamedEntity[]; return new Set(named.map(({ id }) => id)).size === named.length ? named diff --git a/src/domain/analysisView/environmentStateWriteAdoption.ts b/src/domain/analysisView/environmentStateWriteAdoption.ts index 93c52ac5d5..9272e2165d 100644 --- a/src/domain/analysisView/environmentStateWriteAdoption.ts +++ b/src/domain/analysisView/environmentStateWriteAdoption.ts @@ -1,5 +1,6 @@ import { ENTRY_BYTES, textBytes } from "./webNetworkRouteTemplates.js"; import { + ownMember, at, isObject, stateValueRetentionBytes, @@ -88,7 +89,7 @@ export class UnseenEntities { if ( !isObject(object) || idField === undefined || - scalarText(object[idField]) !== id + scalarText(ownMember(object, idField)) !== id ) return undefined; const entity = { diff --git a/src/domain/analysisView/environmentStateWriteApply.test.ts b/src/domain/analysisView/environmentStateWriteApply.test.ts index bce091a9c2..d8d65d7149 100644 --- a/src/domain/analysisView/environmentStateWriteApply.test.ts +++ b/src/domain/analysisView/environmentStateWriteApply.test.ts @@ -754,3 +754,46 @@ it("refuses a create whose composed address reads a member the call does not sen ); expect(create({ title: "New Folder" })).toEqual(err(UNSPELLED)); }); + +it.each(["__proto__", "constructor"])( + "creates and updates an absent own %s field without changing object inheritance", + (field) => { + const plan = { + rules: [{ field, rule: "echo" as const }], + body: { + accepted: [field], + fixed: [], + required: [], + identity: null, + kinds: [], + }, + }; + const sent = Object.fromEntries([[field, { label: "stored" }]]); + const call = callWith(sent); + const created = createdFields(plan, { id: 1 }, call, generator().generate); + const updated = updatedFields( + plan, + { + before: { id: 1 }, + observed: Object.fromEntries([ + ["id", 1], + [field, { label: "old" }], + ]), + }, + { ...call, id: "1" }, + generator().generate, + ); + for (const result of [created, updated]) { + expect(result.ok).toBe(true); + if (!result.ok) throw new Error(result.error); + expect( + Object.getOwnPropertyDescriptor(result.value, field)?.value, + ).toEqual({ label: "stored" }); + expect(Object.getPrototypeOf(result.value)).toBe(Object.prototype); + expect(JSON.parse(JSON.stringify(result.value))).toEqual({ + id: 1, + ...sent, + }); + } + }, +); diff --git a/src/domain/analysisView/environmentStateWriteApply.ts b/src/domain/analysisView/environmentStateWriteApply.ts index 28b552e12e..1d414538bb 100644 --- a/src/domain/analysisView/environmentStateWriteApply.ts +++ b/src/domain/analysisView/environmentStateWriteApply.ts @@ -1,6 +1,8 @@ import type { JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; import { + ownMember, + setOwnMember, at, isObject, pointerTo, @@ -74,13 +76,6 @@ export type GenerateValue = ( const kindOf = (value: JsonValue | undefined): string => value === null ? "null" : Array.isArray(value) ? "array" : typeof value; -/** JSON objects contain only their own members, even when a name also exists on Object.prototype. */ -const ownMember = ( - object: JsonObject, - member: string, -): JsonValue | undefined => - Object.hasOwn(object, member) ? object[member] : undefined; - /** The JSON types `stored` holds members in, for those `observed` shows no value of. */ export const storedKinds = ( members: readonly string[], @@ -197,7 +192,9 @@ export const recordedGeneratedValue = ( value: JsonValue | undefined, ): Applied => { if (rule.rule !== "nested-clock") return ok(value); - const clock = isObject(value) ? value[rule.members[0]!] : undefined; + const clock = isObject(value) + ? ownMember(value, rule.members[0]!) + : undefined; if ( clock === undefined || JSON.stringify(readClock(clock)?.format) !== JSON.stringify(rule.format) @@ -251,7 +248,7 @@ const ruledValue = ( ? err( `The call names no ${rule.from} path value, which ${rule.field} takes.`, ) - : ok(pathValue(observed[rule.field], value)); + : ok(pathValue(ownMember(observed, rule.field), value)); } default: return ok(undefined); @@ -264,9 +261,9 @@ const templated = ( written: JsonObject, rule: Extract, ): JsonValue | undefined => { - const text = observed[rule.field]; - const token = scalarText(observed[rule.from]); - const replacement = scalarText(written[rule.from]); + const text = ownMember(observed, rule.field); + const token = scalarText(ownMember(observed, rule.from)); + const replacement = scalarText(ownMember(written, rule.from)); return typeof text === "string" && token !== undefined && replacement !== undefined @@ -283,14 +280,16 @@ const derive = ( ): string | undefined => { for (const rule of rules) { if (rule.rule === "slug") { - const from = sent[rule.from]; - if (typeof from === "string") written[rule.field] = slugOf(from); + const from = ownMember(sent, rule.from); + if (typeof from === "string") + setOwnMember(written, rule.field, slugOf(from)); } else if (rule.rule === "template") { const value = templated(observed, written, rule); - if (value !== undefined) written[rule.field] = value; - } else if (rule.rule === "stamp") written[rule.field] = rule.value; + if (value !== undefined) setOwnMember(written, rule.field, value); + } else if (rule.rule === "stamp") + setOwnMember(written, rule.field, rule.value); else if (rule.rule === "derived" && Object.hasOwn(sent, rule.member)) - written[rule.field] = rule.value; + setOwnMember(written, rule.field, rule.value); } // Composed strings read the fields the rules above wrote. for (const rule of rules) @@ -302,7 +301,7 @@ const derive = ( ); return `The write composes ${JSON.stringify(rule.field)} from ${listed([...sources])}, and the entity holds no value one of them can spell.`; } - written[rule.field] = value; + setOwnMember(written, rule.field, value); } return undefined; }; @@ -341,9 +340,10 @@ export const createdFields = ( for (const rule of plan.rules) { const value = ruledValue(rule, observed, call, generate, referenced); if (!value.ok) return value; - if (value.value !== undefined) written[rule.field] = value.value; + if (value.value !== undefined) + setOwnMember(written, rule.field, value.value); else if (rule.rule === "echo" && defaults.has(rule.field)) - written[rule.field] = defaults.get(rule.field)!; + setOwnMember(written, rule.field, defaults.get(rule.field)!); else if (rule.rule === "echo" || rule.rule === "reference") delete written[rule.field]; } @@ -404,15 +404,20 @@ export const updatedFields = ( const written: JsonObject = { ...before }; for (const rule of plan.rules) { const stated = statedValue(rule, sent); - if (stated !== undefined) written[rule.field] = stated; + if (stated !== undefined) setOwnMember(written, rule.field, stated); else if (rule.rule === "reference" && Object.hasOwn(sent, rule.member)) { const value = referencedValue(rule, sent, referenced); if (!value.ok) return value; - if (value.value !== undefined) written[rule.field] = value.value; + if (value.value !== undefined) + setOwnMember(written, rule.field, value.value); } else if (rule.rule === "increment") { - const was = integerOf(before[rule.field]); + const was = integerOf(ownMember(before, rule.field)); if (was !== undefined) - written[rule.field] = spelledLike(before[rule.field]!, was + 1); + setOwnMember( + written, + rule.field, + spelledLike(ownMember(before, rule.field)!, was + 1), + ); } else if ( regenerated(rule) || // Child rows are renewed only on an entity that holds them. @@ -420,7 +425,8 @@ export const updatedFields = ( ) { const value = generate(rule); if (!value.ok) return value; - if (value.value !== undefined) written[rule.field] = value.value; + if (value.value !== undefined) + setOwnMember(written, rule.field, value.value); } } const unspelled = derive( @@ -532,10 +538,10 @@ export const writtenResponse = ( pointer, Object.fromEntries( fields.map((field) => { - const value = written.fields![field]!; + const value = ownMember(written.fields!, field)!; const clock = written.clocks?.includes(field) === true - ? readClock(shown[field]) + ? readClock(ownMember(shown, field)) : undefined; return [ field, diff --git a/src/domain/analysisView/environmentStateWriteAttribution.ts b/src/domain/analysisView/environmentStateWriteAttribution.ts index 7791bfc0b4..119ccca5f4 100644 --- a/src/domain/analysisView/environmentStateWriteAttribution.ts +++ b/src/domain/analysisView/environmentStateWriteAttribution.ts @@ -1,4 +1,5 @@ import { + ownMember, renderStateEntity, same, type JsonObject, @@ -412,7 +413,9 @@ export const writtenFieldChecks = ( if (fields === null || compared.length === 0) continue; outcomes.set( tool, - compared.every((field) => same(shown[field], observed[field])), + compared.every((field) => + same(ownMember(shown, field), ownMember(observed, field)), + ), ); } return outcomes; diff --git a/src/domain/analysisView/environmentStateWriteBlame.ts b/src/domain/analysisView/environmentStateWriteBlame.ts index 1bbd2a1060..d285b733c1 100644 --- a/src/domain/analysisView/environmentStateWriteBlame.ts +++ b/src/domain/analysisView/environmentStateWriteBlame.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { + ownMember, at, isObject, locateItem, @@ -271,7 +272,7 @@ const fieldBlame = ( !Object.hasOwn(read.object, field) || read.volatile.has(field) || others.has(field) || - same(response[field], read.object[field]), + same(response[field], ownMember(read.object, field)), ) ); }; @@ -323,11 +324,13 @@ const lackingFields = ( const unruled = new Set(); let unowned = false; for (const field of fields) { - const value = read.object[field] ?? null; + const value = ownMember(read.object, field) ?? null; const owners = [...writes.partial.keys()].filter((name) => { const response = writes.responses.get(name); return ( - value !== null && response !== undefined && same(response[field], value) + value !== null && + response !== undefined && + same(ownMember(response, field), value) ); }); if (owners.length === 0) unowned = true; @@ -349,13 +352,13 @@ const names = ( ? EMBED_SHORT_ID_MATCH_LENGTH : EMBED_MATCH_MIN_LENGTH; return ( - scalarText(item[idField]) === id && + scalarText(ownMember(item, idField)) === id && Object.entries(item).some( ([key, member]) => key !== idField && typeof member === "string" && member.length >= length && - entity[key] === member, + ownMember(entity, key) === member, ) ); }; @@ -383,7 +386,7 @@ const embeds = ( entity: JsonObject, idField: string, ): boolean => { - const id = scalarText(entity[idField]); + const id = scalarText(ownMember(entity, idField)); let found = false; if (id !== undefined) eachObject(value, (item) => { @@ -422,7 +425,9 @@ const embeddingCreates = ( if (entity.type === read.type && entity.id === read.entity?.id) continue; const held = heldOf(entity); const id = - held === undefined ? undefined : scalarText(held.fields[held.idField]); + held === undefined + ? undefined + : scalarText(ownMember(held.fields, held.idField)); if (held === undefined || id === undefined) continue; idFields.add(held.idField); const key = `${held.idField}\u0000${id}`; @@ -434,11 +439,11 @@ const embeddingCreates = ( const tools = new Set(); if (byId.size === 0) return []; for (const [field, value] of Object.entries(read.object)) { - const before = read.entity?.fields[field]; + const before = ownMember(read.entity?.fields, field); if (same(before, value)) continue; eachObject(value, (item) => { for (const idField of idFields) { - const id = scalarText(item[idField]); + const id = scalarText(ownMember(item, idField)); for (const entity of id === undefined ? [] : (byId.get(`${idField}\u0000${id}`) ?? [])) diff --git a/src/domain/analysisView/environmentStateWriteCalls.ts b/src/domain/analysisView/environmentStateWriteCalls.ts index ff958dfec3..68fcfdb866 100644 --- a/src/domain/analysisView/environmentStateWriteCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCalls.ts @@ -1,7 +1,7 @@ import { jsonTextCodeUnits } from "../jsonSerialization.js"; import { digestCanonicalValue } from "../canonicalDigest.js"; import type { JsonValue } from "../jsonValue.js"; -import { same, type JsonObject } from "./environmentStateRules.js"; +import { ownMember, same, type JsonObject } from "./environmentStateRules.js"; import { storedKinds } from "./environmentStateWriteApply.js"; import { composedValue } from "./environmentStateWriteComposed.js"; import type { explainUpdate } from "./environmentStateWriteExplain.js"; @@ -48,7 +48,7 @@ export const disagreesWithCall = ( (rule) => (unsentChange(rule) && Object.hasOwn(call.after, rule.field) && - !same(call.written[rule.field], call.after[rule.field])) || + !same(ownMember(call.written, rule.field), call.after[rule.field])) || (regenerates(rule) && call.kept.includes(rule.field)), ); @@ -60,8 +60,8 @@ export const steadyFields = ( ): string[] => rules.flatMap((rule) => rule.rule === "set" && - same(before[rule.field], rule.value) && - same(after[rule.field], rule.value) + same(ownMember(before, rule.field), rule.value) && + same(ownMember(after, rule.field), rule.value) ? [rule.field] : [], ); @@ -265,7 +265,8 @@ export const appliedUpdate = ( ); const derived = Object.keys(written).filter( (field) => - !Object.hasOwn(before, field) || !same(written[field], before[field]), + !Object.hasOwn(before, field) || + !same(written[field], ownMember(before, field)), ); const changed = { fields: [...new Set([...sent, ...derived])], diff --git a/src/domain/analysisView/environmentStateWriteChanges.ts b/src/domain/analysisView/environmentStateWriteChanges.ts index c45b496360..35a85160ae 100644 --- a/src/domain/analysisView/environmentStateWriteChanges.ts +++ b/src/domain/analysisView/environmentStateWriteChanges.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { + ownMember, isObject, same, type JsonObject, @@ -52,8 +53,8 @@ export const changeRule = ( field: string, slugs: ReadonlyMap, ): FieldRule | undefined => { - const before = evidence.before[field]; - const after = evidence.after[field]!; + const before = ownMember(evidence.before, field); + const after = ownMember(evidence.after, field)!; if (readClock(after) !== undefined && isClockNear(after, evidence.startMs)) return { field, rule: "clock", format: readClock(after)!.format }; if (freshUuid(evidence, field)) return { field, rule: "unique" }; @@ -93,7 +94,7 @@ const actionRule = ( evidence: UpdateEvidence, field: string, ): FieldRule | undefined => { - const after = evidence.after[field]!; + const after = ownMember(evidence.after, field)!; return evidence.action === true && (after === null || typeof after !== "object") && readClock(after) === undefined @@ -123,8 +124,8 @@ export const heldClock = ( sent: JsonValue, ): boolean => inCallSecond(sent, evidence.startMs) && - same(evidence.before[member], sent) && - same(evidence.after[member], sent); + same(ownMember(evidence.before, member), sent) && + same(ownMember(evidence.after, member), sent); /** A field naming when its entity was made (`createdAt`, `row_created_timestamp`), which no update stamps. */ const CREATION_CLOCK = /creat(?:ed|ion)/iu; @@ -145,13 +146,13 @@ const sentSlugSource = ( evidence: UpdateEvidence, member: string, ): string | undefined => { - const stored = evidence.after[member]; + const stored = ownMember(evidence.after, member); if (typeof stored !== "string") return undefined; return Object.entries(evidence.call.body).find( ([other, value]) => other !== member && typeof value === "string" && - same(evidence.after[other], value) && + same(ownMember(evidence.after, other), value) && slugOf(value) === stored, )?.[0]; }; @@ -164,9 +165,9 @@ const restampedClock = ( ): boolean => !CREATION_CLOCK.test(member) && readClock(sent) !== undefined && - same(evidence.before[member], sent) && - readClock(evidence.after[member]) !== undefined && - isClockNear(evidence.after[member]!, evidence.startMs); + same(ownMember(evidence.before, member), sent) && + readClock(ownMember(evidence.after, member)) !== undefined && + isClockNear(ownMember(evidence.after, member)!, evidence.startMs); /** How an update stored a sent member: stamped anew as the one held clock it sent back other than a creation clock, echoed, stamped anew or slugged from another member in place of what it sent, renewed as a fresh UUID, or as a constant. */ export const sentRule = ( @@ -174,26 +175,30 @@ export const sentRule = ( member: string, held: readonly string[], ): FieldRule => { - const sent = evidence.call.body[member]!; + const sent = ownMember(evidence.call.body, member)!; if (held.length === 1 && held[0] === member && !CREATION_CLOCK.test(member)) return { field: member, rule: "held-clock", format: readClock(sent)!.format, }; - if (same(evidence.after[member], sent)) + if (same(ownMember(evidence.after, member), sent)) return { field: member, rule: "echo" }; - if (same(evidence.after[member], sortedStrings(sent))) + if (same(ownMember(evidence.after, member), sortedStrings(sent))) return { field: member, rule: "echo", sorted: true }; if (restampedClock(evidence, member, sent)) return { field: member, rule: "clock", - format: readClock(evidence.after[member])!.format, + format: readClock(ownMember(evidence.after, member))!.format, }; const from = sentSlugSource(evidence, member); if (from !== undefined) return { field: member, rule: "slug", from }; return freshUuid(evidence, member) ? { field: member, rule: "unique" } - : { field: member, rule: "constant", value: evidence.after[member]! }; + : { + field: member, + rule: "constant", + value: ownMember(evidence.after, member)!, + }; }; diff --git a/src/domain/analysisView/environmentStateWriteChecks.ts b/src/domain/analysisView/environmentStateWriteChecks.ts index 4fd3a01aa8..f0eabd8b35 100644 --- a/src/domain/analysisView/environmentStateWriteChecks.ts +++ b/src/domain/analysisView/environmentStateWriteChecks.ts @@ -2,6 +2,7 @@ import { ServedOrder } from "./environmentStateWriteOrder.js"; import type { JsonValue } from "../jsonValue.js"; import { compositeKey } from "../unicodeCodePointOrder.js"; import { + ownMember, at, decodedSegment, isObject, @@ -408,7 +409,10 @@ const listCheck = ( ) return { pass: false }; const observed = new Map( - check.items.map((item) => [scalarText(item[check.idField]) ?? "", item]), + check.items.map((item) => [ + scalarText(ownMember(item, check.idField)) ?? "", + item, + ]), ); const rendered = members.map((entity) => renderStateEntity(entity, view, check.volatile, observed.get(entity.id)), @@ -450,7 +454,7 @@ const listedTargets = ( const targets: string[] = []; const unheld: string[] = []; const ids = [...check.baseItems, ...check.items].map( - (item) => scalarText(item[check.idField]) ?? "", + (item) => scalarText(ownMember(item, check.idField)) ?? "", ); for (const id of new Set(ids)) (state.entity(view.type, check.collection, id) === undefined @@ -525,7 +529,7 @@ const checkAllList = ( ), ], unheld: check.items - .map((item) => scalarText(item[check.idField]) ?? "") + .map((item) => scalarText(ownMember(item, check.idField)) ?? "") .filter((id) => !held.has(id)) .map((id) => entityTarget(view.type, collection, id)), }; @@ -538,7 +542,8 @@ const checkRecordedList = ( check: ListBodies, ): CheckOutcome => { const { collection } = check; - const idOf = (item: JsonObject) => scalarText(item[check.idField]) ?? ""; + const idOf = (item: JsonObject) => + scalarText(ownMember(item, check.idField)) ?? ""; const members = check.baseItems.flatMap((item) => { const entity = state.entity(view.type, collection, idOf(item)); return entity === undefined ? [] : [entity]; diff --git a/src/domain/analysisView/environmentStateWriteComposed.ts b/src/domain/analysisView/environmentStateWriteComposed.ts index 0017aa905f..5572c6b296 100644 --- a/src/domain/analysisView/environmentStateWriteComposed.ts +++ b/src/domain/analysisView/environmentStateWriteComposed.ts @@ -1,5 +1,10 @@ import type { JsonValue } from "../jsonValue.js"; -import { same, scalarText, type JsonObject } from "./environmentStateRules.js"; +import { + ownMember, + same, + scalarText, + type JsonObject, +} from "./environmentStateRules.js"; import { COPY_MIN_LENGTH } from "./environmentStateWriteCopies.js"; import { GENERATED } from "./environmentStateWriteGenerated.js"; import type { ComposedPart, FieldRule } from "./environmentStateWriteRules.js"; @@ -39,7 +44,7 @@ export const composedPartText = ( fields: JsonObject, ): string | undefined => { if ("text" in part) return part.text; - const value = fields[part.from]; + const value = ownMember(fields, part.from); if (part.as === "clock") return clockDigits(value, part.pattern); if (part.as === "value") return scalarText(value); return typeof value === "string" ? slugOf(value) : undefined; @@ -208,14 +213,14 @@ export const createdComposer = ( zones.get(left.from) === zones.get(right.from)); let index: CandidateIndex | undefined; return (field) => { - const text = entity[field]; + const text = ownMember(entity, field); if (typeof text !== "string") return undefined; index ??= indexed( rules.flatMap((rule) => SOURCES.has(rule.rule) ? candidatesOf( rule.field, - entity[rule.field], + ownMember(entity, rule.field), // A generated value is short and still the call's own, as a template's is. GENERATED.has(rule.rule) ? 1 : COPY_MIN_LENGTH, ) @@ -246,8 +251,8 @@ export const composedChange = ( }, ): FieldRule | undefined => { const { before, after, sent, idField } = entity; - const text = after[field]; - if (typeof text !== "string" || typeof before[field] !== "string") + const text = ownMember(after, field); + if (typeof text !== "string" || typeof ownMember(before, field) !== "string") return undefined; const known = Object.keys(after).filter( (other) => @@ -260,14 +265,14 @@ export const composedChange = ( known.flatMap((other) => candidatesOf( other, - after[other], + ownMember(after, other), other === idField ? 1 : COPY_MIN_LENGTH, ), ), ); const parts = segmented(text, index, samePart); return parts === undefined || - composedValue(parts, before) !== before[field] || + composedValue(parts, before) !== ownMember(before, field) || heldSpellings(parts, field, entity.others) === undefined ? undefined : { field, rule: "composed", parts }; diff --git a/src/domain/analysisView/environmentStateWriteCopies.ts b/src/domain/analysisView/environmentStateWriteCopies.ts index f4a736c9f1..6059c467d1 100644 --- a/src/domain/analysisView/environmentStateWriteCopies.ts +++ b/src/domain/analysisView/environmentStateWriteCopies.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { + ownMember, pointerTokens, scalarText, type JsonObject, @@ -18,10 +19,10 @@ export const templateRule = ( field: string, sources: readonly FieldRule[], ): FieldRule | undefined => { - const value = entity[field]; + const value = ownMember(entity, field); if (typeof value !== "string") return undefined; for (const rule of sources) { - const token = scalarText(entity[rule.field]); + const token = scalarText(ownMember(entity, rule.field)); const generated = GENERATED.has(rule.rule); if ( rule.field !== field && @@ -95,7 +96,7 @@ export const strandedCopies = ( ), ); const sources = rules.flatMap((rule) => { - const token = scalarText(entity[rule.field]); + const token = scalarText(ownMember(entity, rule.field)); return (rule.rule === "copy" || rule.rule === "echo") && token !== undefined && token.length >= COPY_MIN_LENGTH @@ -105,7 +106,7 @@ export const strandedCopies = ( const holders = holdersOf( new Map( texts.flatMap((field) => { - const value = entity[field]; + const value = ownMember(entity, field); return typeof value === "string" ? [[field, value] as const] : []; }), ), @@ -187,7 +188,7 @@ export const referencedMembers = ( if (referenced.length === 0) continue; // A reference repeated under another reference's name is the same entity named twice, not its text. const repeats = (field: string): boolean => { - const value = entity[field]; + const value = ownMember(entity, field); return ( typeof value === "string" && value.length >= COPY_MIN_LENGTH && @@ -226,7 +227,7 @@ export const instantMembers = ( constants: readonly string[], ): Set => { const instants = constants.flatMap((field) => { - const at = instantOf(entity[field]); + const at = instantOf(ownMember(entity, field)); return at === undefined ? [] : [{ field, at }]; }); return new Set( diff --git a/src/domain/analysisView/environmentStateWriteCreate.ts b/src/domain/analysisView/environmentStateWriteCreate.ts index 18992de54f..2b77b8df67 100644 --- a/src/domain/analysisView/environmentStateWriteCreate.ts +++ b/src/domain/analysisView/environmentStateWriteCreate.ts @@ -1,6 +1,7 @@ import { jsonTextCodeUnits } from "../jsonSerialization.js"; import type { JsonValue } from "../jsonValue.js"; import { + ownMember, collectionPathOf, isObject, isParam, @@ -223,7 +224,7 @@ const strandedInteger = ( rules.some((rule) => { const digits = rule.rule === "unique" - ? generatedIntegerDigits(entity[rule.field]) + ? generatedIntegerDigits(ownMember(entity, rule.field)) : undefined; return ( digits !== undefined && @@ -346,7 +347,7 @@ const buildCreate = ( // Identity and clock orders first, then any other scalar field the collection may be sorted by. const scalars = Object.keys(members[0] ?? {}).filter((field) => members.every((member) => - ["string", "number"].includes(typeof member[field]), + ["string", "number"].includes(typeof ownMember(member, field)), ), ); const generated = explained.rules.some( @@ -437,12 +438,16 @@ const followsSequences = ( rule.scope === "type" || entity.collection === created.collection, ); const next = nextSequence( - nextInteger(fieldsOf(scope), rule.field, draft.observed?.[rule.field]), + nextInteger( + fieldsOf(scope), + rule.field, + ownMember(draft.observed, rule.field), + ), draft.sequences.get(key), ); return ( - created.fields[rule.field] === - spelledLike(draft.observed?.[rule.field] ?? "", next) + ownMember(created.fields, rule.field) === + spelledLike(ownMember(draft.observed, rule.field) ?? "", next) ); }); @@ -467,7 +472,7 @@ const contradictsDefault = ( rule.rule === "echo" && !Object.hasOwn(call.body, rule.field) && Object.hasOwn(call.observed, rule.field) && - !same(call.observed[rule.field], written[rule.field]), + !same(call.observed[rule.field], ownMember(written, rule.field)), ); /** Whether a later create preserves generation shapes and its derived fields reproduce the captured observation. */ @@ -479,13 +484,14 @@ const followsCreateRules = ( }, ): boolean => draft.plan.rules.every((rule) => { - const value = created.fields[rule.field]; - const exemplar = draft.observed?.[rule.field]; + const value = ownMember(created.fields, rule.field); + const exemplar = ownMember(draft.observed, rule.field); if (rule.rule === "composed" || rule.rule === "reference") return ( !Object.hasOwn(created.observed, rule.field) || - value === created.observed[rule.field] || - (rule.rule === "reference" && same(value, created.observed[rule.field])) + value === ownMember(created.observed, rule.field) || + (rule.rule === "reference" && + same(value, ownMember(created.observed, rule.field))) ); if (rule.rule === "clock") return ( @@ -504,7 +510,7 @@ const followsCreateRules = ( : isOpaqueToken(value, exemplar)) && !context.state .ofType(created.type) - .some((held) => held.fields[rule.field] === value) + .some((held) => ownMember(held.fields, rule.field) === value) ); return ( rule.rule !== "identity" || rule.generator !== "uuid" || isUuid(value) @@ -517,7 +523,7 @@ const fitClocks = (draft: WriteDraft, fields: JsonObject): void => { if (rule.rule !== "clock") return rule; const format = fittedClockFormat( rule.format, - fields[rule.field], + ownMember(fields, rule.field), clockEvidence(draft, rule.field), ); return format === undefined || format === rule.format @@ -540,8 +546,8 @@ const takeSequences = ( ): void => { for (const { rule, key } of sequencesOf(draft, created.collection)) { const value = sequenceNumber( - created.fields[rule.field], - draft.observed?.[rule.field], + ownMember(created.fields, rule.field), + ownMember(draft.observed, rule.field), ); if (value !== undefined) draft.sequences.set(key, value); } @@ -736,8 +742,8 @@ export const applyCreate = ( (rule) => recordedGeneratedValue( rule, - draft.observed![rule.field], - observed.object[rule.field] ?? null, + ownMember(draft.observed!, rule.field), + ownMember(observed.object, rule.field) ?? null, ), referencedIn(context), ); @@ -751,7 +757,9 @@ export const applyCreate = ( envelope: request?.envelope, }); const fields = fieldsOfCall(); - const id = fields.ok ? scalarText(fields.value[idField]) : undefined; + const id = fields.ok + ? scalarText(ownMember(fields.value, idField)) + : undefined; if ( !fields.ok || id === undefined || diff --git a/src/domain/analysisView/environmentStateWriteCreateCalls.ts b/src/domain/analysisView/environmentStateWriteCreateCalls.ts index 2146c5b2e9..168545e645 100644 --- a/src/domain/analysisView/environmentStateWriteCreateCalls.ts +++ b/src/domain/analysisView/environmentStateWriteCreateCalls.ts @@ -1,5 +1,5 @@ import type { JsonValue } from "../jsonValue.js"; -import { same, type JsonObject } from "./environmentStateRules.js"; +import { ownMember, same, type JsonObject } from "./environmentStateRules.js"; import { storedKinds } from "./environmentStateWriteApply.js"; import { fittedClockFormat } from "./environmentStateWriteClockFormats.js"; import { @@ -127,9 +127,12 @@ const mergedField = ( // This call echoes a member the plan's calls left out: what they stored there is its default. if (after !== undefined) return after.rule === "echo" && !calls.required.includes(field) - ? { rule: after, default: { member: field, value: exemplar[field]! } } + ? { + rule: after, + default: { member: field, value: ownMember(exemplar, field)! }, + } : undefined; - return same(exemplar[field], later.entity[field]) ? {} : undefined; + return same(ownMember(exemplar, field), later.entity[field]) ? {} : undefined; }; /** @@ -151,7 +154,7 @@ export const mergedCreatePlan = ( }, earlier: EarlierEvidence = { kept: new Set(), - clockOf: (field) => exemplar[field], + clockOf: (field) => ownMember(exemplar, field), }, ): { readonly rules: FieldRule[]; readonly body: WriteBody } | undefined => { const held = new Map(plan.rules.map((rule) => [rule.field, rule])); diff --git a/src/domain/analysisView/environmentStateWriteEffects.ts b/src/domain/analysisView/environmentStateWriteEffects.ts index 12db6e04bf..a5ca517de0 100644 --- a/src/domain/analysisView/environmentStateWriteEffects.ts +++ b/src/domain/analysisView/environmentStateWriteEffects.ts @@ -1,6 +1,7 @@ import { jsonTextCodeUnits } from "../jsonSerialization.js"; import type { JsonValue } from "../jsonValue.js"; import { + ownMember, isObject, locateItem, at, @@ -103,7 +104,7 @@ export const clockEvidence = ( draft: WriteDraft, field: string, ): JsonValue | undefined => - draft.fixedWidth.get(field) ?? draft.observed?.[field]; + draft.fixedWidth.get(field) ?? ownMember(draft.observed, field); /** A write observation plus the fields its selected read view actually covers, if any. */ type UpdateObservation = Observed & { @@ -599,8 +600,8 @@ export const updateEntity = ( (rule) => recordedGeneratedValue( rule, - entity.fields[rule.field], - after.object[rule.field], + ownMember(entity.fields, rule.field), + ownMember(after.object, rule.field), ), referencedIn(context), ); @@ -663,7 +664,7 @@ const fitUpdateClocks = ( return rule; const format = fittedClockFormat( rule.format, - after[rule.field], + ownMember(after, rule.field), clockEvidence(draft, rule.field), ); return format === undefined ? rule : { ...rule, format }; @@ -682,7 +683,10 @@ const fitUpdateClocks = ( const held = planned.get(rule.field); return rule.rule === "clock" && held?.rule === "clock" && - matchesClockFormat(held.format, readClock(after[rule.field])) + matchesClockFormat( + held.format, + readClock(ownMember(after, rule.field)), + ) ? { ...rule, format: held.format } : rule; }), @@ -710,7 +714,7 @@ const contradictsGeneratedClock = ( ) ) return false; - const previous = readClock(before[rule.field]); + const previous = readClock(ownMember(before, rule.field)); if (previous === undefined) return false; const next = readClock(after[rule.field]); return ( diff --git a/src/domain/analysisView/environmentStateWriteExplain.ts b/src/domain/analysisView/environmentStateWriteExplain.ts index 8aef269f26..644d04bec1 100644 --- a/src/domain/analysisView/environmentStateWriteExplain.ts +++ b/src/domain/analysisView/environmentStateWriteExplain.ts @@ -1,6 +1,7 @@ import type { JsonValue } from "../jsonValue.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; import { + ownMember, isObject, locateItem, pointerTo, @@ -125,7 +126,7 @@ export const nextInteger = ( ): number | undefined => { let largest: number | undefined; for (const member of members) { - const value = sequenceNumber(member[field], exemplar); + const value = sequenceNumber(ownMember(member, field), exemplar); if (value !== undefined && (largest === undefined || value > largest)) largest = value; } @@ -179,10 +180,12 @@ const learnedClockFormat = ( ): ClockFormat => { let learned = format; for (const member of type) { - const fitted = fittedClockFormat(learned, member[field], value); + const fitted = fittedClockFormat(learned, ownMember(member, field), value); if (fitted?.kind === "iso" && fitted.trimmed === true) learned = fitted; } - return type.some((member) => showsFixedWidth(format, member[field])) + return type.some((member) => + showsFixedWidth(format, ownMember(member, field)), + ) ? format : learned; }; @@ -193,7 +196,7 @@ export const createdInteger = ( entity: JsonObject, type: readonly JsonObject[], ): "independent" | "linked" | undefined => { - const value = entity[field]; + const value = ownMember(entity, field); const digits = generatedIntegerDigits(value); if ( digits === undefined || @@ -203,10 +206,10 @@ export const createdInteger = ( return undefined; const held = new Set([digits]); const distinct = type.every((member) => { - const other = generatedIntegerDigits(member[field]); + const other = generatedIntegerDigits(ownMember(member, field)); if ( other === undefined || - typeof member[field] !== typeof value || + typeof ownMember(member, field) !== typeof value || other.length !== digits.length || held.has(other) ) @@ -252,7 +255,7 @@ const generatedRule = ( if ( (isUuid(value) && evidence.type.length > 0 && - evidence.type.every((member) => member[field] !== value)) || + evidence.type.every((member) => ownMember(member, field) !== value)) || createdInteger(field, evidence.entity, evidence.type) === "independent" ) return { field, rule: "unique" }; @@ -311,7 +314,9 @@ export const explainCreate = ( // Generated values first, then copies: an echoed value may recur in unrelated text, a copied one is the call's own unless also echoed. const echoed = new Set( rules.flatMap((rule) => - rule.rule === "echo" ? [scalarText(evidence.entity[rule.field])] : [], + rule.rule === "echo" + ? [scalarText(ownMember(evidence.entity, rule.field))] + : [], ), ); const sources = [ @@ -319,7 +324,7 @@ export const explainCreate = ( ...rules.filter( (rule) => rule.rule === "copy" && - !echoed.has(scalarText(evidence.entity[rule.field])), + !echoed.has(scalarText(ownMember(evidence.entity, rule.field))), ), ]; const constants: string[] = []; @@ -375,7 +380,9 @@ export const explainCreate = ( ), // A template rebuilds only its generated value, so other text it keeps ties it like a constant. tied: [ - ...[...constants, ...templates].map((field) => evidence.entity[field]!), + ...[...constants, ...templates].map((field) => + ownMember(evidence.entity, field)!, + ), ...literals, ].some((text) => tokens.some((token) => containsDelimited(text, token))), }; @@ -388,7 +395,7 @@ const echoedSlugs = ( ): Map => { const slugs = new Map(); for (const member of echoed) { - const sent = body[member]; + const sent = ownMember(body, member); if (typeof sent === "string" && !slugs.has(slugOf(sent))) slugs.set(slugOf(sent), member); } @@ -404,7 +411,7 @@ const keptReference = ( if (known === undefined || evidence.named === undefined) return undefined; const shown = referenceRule( field, - evidence.after[field], + ownMember(evidence.after, field), evidence.call.body, evidence.named, ); @@ -439,7 +446,12 @@ export const explainUpdate = ( // The identity is the entity's key: the path's id repeated is the identity member; another value is one the application ignored, or used to re-key the entity, which no rule models. if (member === evidence.idField) { if (scalarText(sent) === evidence.id) identity = member; - else if (!same(evidence.after[member], evidence.before[member])) + else if ( + !same( + ownMember(evidence.after, member), + ownMember(evidence.before, member), + ) + ) unexplained.push(member); continue; } @@ -465,7 +477,9 @@ export const explainUpdate = ( !(evidence.shown ?? []).includes(field)) ) continue; - if (same(evidence.before[field], evidence.after[field])) { + if ( + same(ownMember(evidence.before, field), ownMember(evidence.after, field)) + ) { const kept = keptReference(evidence, field); if (kept !== undefined) rules.push(kept); continue; @@ -553,7 +567,10 @@ const orderedBy = ( ): boolean => members.every((member, index) => { if (index === 0) return true; - const order = compareValues(members[index - 1]![field], member[field]); + const order = compareValues( + ownMember(members[index - 1]!, field), + ownMember(member, field), + ); return ( order !== undefined && (direction === "ascending" ? order < 0 : order > 0) ); @@ -572,17 +589,23 @@ export const randomIdentities = ( idField: string, clocks: readonly string[], ): boolean => { - const ids = members.map((member) => scalarText(member[idField]) ?? ""); + const ids = members.map( + (member) => scalarText(ownMember(member, idField)) ?? "", + ); if (ids.length > 0 && ids.every((id) => UUID_V4.test(id))) return true; if (members.length < 2 || clocks.length === 0) return false; const byId = members.toSorted( - (left, right) => compareValues(left[idField], right[idField]) ?? 0, + (left, right) => + compareValues(ownMember(left, idField), ownMember(right, idField)) ?? 0, ); return clocks.every((clock) => byId.some( (member, index) => index > 0 && - (compareValues(byId[index - 1]![clock], member[clock]) ?? 1) > 0, + (compareValues( + ownMember(byId[index - 1]!, clock), + ownMember(member, clock), + ) ?? 1) > 0, ), ); }; @@ -628,7 +651,10 @@ export const insertIndex = ( ): number => { if (rule.rule !== "sorted") return rule.rule === "first" ? 0 : members.length; const index = members.findIndex((member) => { - const order = compareValues(fields[rule.field], member[rule.field]); + const order = compareValues( + ownMember(fields, rule.field), + ownMember(member, rule.field), + ); return ( order !== undefined && (rule.direction === "ascending" ? order < 0 : order > 0) diff --git a/src/domain/analysisView/environmentStateWriteGenerated.ts b/src/domain/analysisView/environmentStateWriteGenerated.ts index c5881019df..e52999cfe0 100644 --- a/src/domain/analysisView/environmentStateWriteGenerated.ts +++ b/src/domain/analysisView/environmentStateWriteGenerated.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { + ownMember, isObject, pointerTo, type JsonObject, @@ -27,8 +28,8 @@ export const freshUuid = ( evidence: GeneratedUpdateEvidence, field: string, ): boolean => { - const before = evidence.before[field]; - const after = evidence.after[field]; + const before = ownMember(evidence.before, field); + const after = ownMember(evidence.after, field); if (!isUuid(before) || !isUuid(after)) return false; const fresh = after.toLowerCase(); // Scalar generation and nested renewal have separate owners. A shared output UUID @@ -97,7 +98,11 @@ const pairUuids = (before: JsonValue, after: JsonValue): Paired | undefined => { if (keys.length !== Object.keys(now).length) return undefined; for (const key of keys) { if (!Object.hasOwn(now, key)) return undefined; - pending.push([was[key]!, now[key]!, childPointer(pointer, key)]); + pending.push([ + ownMember(was, key)!, + now[key]!, + childPointer(pointer, key), + ]); } } else if (was !== now) return undefined; } @@ -146,7 +151,7 @@ export const renewedRule = ( others: readonly JsonObject[], ): FieldRule | undefined => { const renewal = renewedUuids(change.before, change.after); - const held = uuidsIn(others.map((other) => other[field] ?? null)); + const held = uuidsIn(others.map((other) => ownMember(other, field) ?? null)); return renewal !== undefined && [...renewal.uuids.values()].every((uuid) => !held.has(uuid.toLowerCase())) ? { field, rule: "renewed", pointers: renewal.pointers } @@ -178,7 +183,10 @@ export const unexplainedRenewals = ( const fields = new Set(); for (const rule of rules) { if (rule.rule !== "renewed") continue; - const renewal = renewedUuids(before[rule.field]!, after[rule.field]!); + const renewal = renewedUuids( + ownMember(before, rule.field)!, + ownMember(after, rule.field)!, + ); for (const [was, now] of renewal?.uuids ?? []) { if ( held.has(now) || diff --git a/src/domain/analysisView/environmentStateWriteLinks.ts b/src/domain/analysisView/environmentStateWriteLinks.ts index ef4493fb5f..e7c7c340ff 100644 --- a/src/domain/analysisView/environmentStateWriteLinks.ts +++ b/src/domain/analysisView/environmentStateWriteLinks.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { + ownMember, collectionPathOf, IDENTITY_NAMES, identityCandidates, @@ -37,7 +38,7 @@ const succeeded = (record: StateRecordFacts): boolean => const identityValues = (object: JsonValue | undefined): string[] => isObject(object) ? IDENTITY_NAMES.flatMap((name) => { - const value = scalarText(object[name]); + const value = scalarText(ownMember(object, name)); return value === undefined ? [] : [value]; }) : []; @@ -101,7 +102,7 @@ const listedIds = (body: JsonValue | undefined): Set => { ...items.flatMap(identityValues), ...(suffixed === undefined ? [] - : items.flatMap((item) => scalarText(item[suffixed]) ?? [])), + : items.flatMap((item) => scalarText(ownMember(item, suffixed)) ?? [])), ]); }; diff --git a/src/domain/analysisView/environmentStateWriteObserve.ts b/src/domain/analysisView/environmentStateWriteObserve.ts index 30956c85c0..1b228a0d9c 100644 --- a/src/domain/analysisView/environmentStateWriteObserve.ts +++ b/src/domain/analysisView/environmentStateWriteObserve.ts @@ -1,6 +1,7 @@ import type { JsonValue } from "../jsonValue.js"; import { overlapsWrite } from "./environmentStateWriteAttribution.js"; import { + ownMember, at, collectionPathOf, isObject, @@ -86,7 +87,9 @@ export const newEntityIn = ( if (body === undefined) return undefined; for (const tier of entitySlots(body)) { const found = tier.flatMap(([pointer, object]) => { - const id = isObject(object) ? scalarText(object[idField]) : undefined; + const id = isObject(object) + ? scalarText(ownMember(object, idField)) + : undefined; // An object holding only the identity names the entity without showing it. return isObject(object) && id !== undefined && @@ -229,7 +232,8 @@ const itemObservation = ( : entitySlots(body) .flat() .find(([at]) => at === pointer)?.[1]; - return isObject(object) && scalarText(object[target.idField]) === id + return isObject(object) && + scalarText(ownMember(object, target.idField)) === id ? { ordinal: record.ordinal, pointer, object, id } : undefined; }); @@ -248,7 +252,7 @@ const listObservation = ( .map((object, index) => ({ object, index, - id: scalarText(object[target.idField]), + id: scalarText(ownMember(object, target.idField)), })) .filter( (entry): entry is { object: JsonObject; index: number; id: string } => diff --git a/src/domain/analysisView/environmentStateWriteOmitted.ts b/src/domain/analysisView/environmentStateWriteOmitted.ts index 10dc019bde..6a076d6e41 100644 --- a/src/domain/analysisView/environmentStateWriteOmitted.ts +++ b/src/domain/analysisView/environmentStateWriteOmitted.ts @@ -1,4 +1,5 @@ import { + ownMember, at, isObject, listItems, @@ -120,11 +121,11 @@ const heldEntities = ( ? (listItems(read.body!, view.pointer) ?? []) : isObject(object) && read.itemId !== undefined && - scalarText(object[read.idField]) === read.itemId + scalarText(ownMember(object, read.idField)) === read.itemId ? [object] : []; return shown.filter((entity) => { - const id = scalarText(entity[read.idField]); + const id = scalarText(ownMember(entity, read.idField)); return ( id !== undefined && state.entity(view.type, read.collection, id) !== undefined diff --git a/src/domain/analysisView/environmentStateWriteRules.ts b/src/domain/analysisView/environmentStateWriteRules.ts index 8af5ab58a3..041b9d212a 100644 --- a/src/domain/analysisView/environmentStateWriteRules.ts +++ b/src/domain/analysisView/environmentStateWriteRules.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "../jsonValue.js"; import { + ownMember, decodedSegment, isParam, normalizedPath, @@ -148,10 +149,10 @@ export const siblingTrimmedClocks = ( const peer = trimmed.get(`${rule.format.separator}${rule.format.zone}`); if (peer === undefined) return rule; const format = later.some((fields) => - showsFixedWidth(rule.format, fields[rule.field]), + showsFixedWidth(rule.format, ownMember(fields, rule.field)), ) ? rule.format - : trimmedLike(rule.format, peer, values?.[rule.field]); + : trimmedLike(rule.format, peer, ownMember(values, rule.field)); return format === rule.format ? rule : { ...rule, format }; }); }; @@ -178,7 +179,7 @@ export const keptOrders = (body: JsonObject, stored: JsonObject): string[] => Object.keys(body).filter( (member) => sortedStrings(body[member]) !== undefined && - same(stored[member], body[member]), + same(ownMember(stored, member), body[member]), ); /** One part of a composed string: literal text, or another written field as written, slugged, or as a clock's calendar digits. */ diff --git a/src/domain/analysisView/environmentStateWriteSorted.ts b/src/domain/analysisView/environmentStateWriteSorted.ts index d361efbfd2..73a5aab75a 100644 --- a/src/domain/analysisView/environmentStateWriteSorted.ts +++ b/src/domain/analysisView/environmentStateWriteSorted.ts @@ -1,6 +1,8 @@ import type { JsonValue } from "../jsonValue.js"; import { compareUnicodeCodePoints as compare } from "../unicodeCodePointOrder.js"; import { + ownMember, + setOwnMember, at, isObject, isStringArray, @@ -35,7 +37,7 @@ const ordered = (value: JsonValue | undefined): value is string[] => /** Whether no object a read shows lists the field as a string array out of order. */ const listedSorted = (shown: readonly JsonObject[], field: string): boolean => shown.every((object) => { - const value = object[field]; + const value = ownMember(object, field); return !ordered(value) || inOrder(value); }); @@ -50,7 +52,7 @@ const shownBy = ( )?.fields; return own !== undefined && Object.hasOwn(own, field) ? own[field] - : entity.fields[field]; + : ownMember(entity.fields, field); }; /** Fields every initial entity shows, as the view renders it, as a sorted string array or null, one with two distinct strings. */ @@ -231,9 +233,9 @@ export class SortedArrays { if (!isObject(value)) return value; const object = { ...value }; for (const field of fields) { - const array = object[field]; + const array = ownMember(object, field); if (array !== undefined && isStringArray(array)) - object[field] = array.toSorted(compare); + setOwnMember(object, field, array.toSorted(compare)); } return object; }; @@ -290,11 +292,11 @@ export class SortedArrays { const idField = this.facts.idField(view.type) ?? ""; const existing = this.#existing.get(`${view.type}\u0000${field}`)!; return shown.some((object) => { - const value = object[field]; - const id = scalarText(object[idField]); + const value = ownMember(object, field); + const id = scalarText(ownMember(object, idField)); if (!ordered(value) || id === undefined) return false; return this.facts.state.named(id).some((entity) => { - const written = entity.fields[field]; + const written = ownMember(entity.fields, field); return ( targets.has( entityTarget(entity.type, entity.collection, entity.id), @@ -332,8 +334,8 @@ export class SortedArrays { if (values === undefined) this.#existing.set(key, (values = new Set())); const held = entities.flatMap((entity) => [ - entity.fields[field], - ...entity.view_fields.map((view) => view.fields[field]), + ownMember(entity.fields, field), + ...entity.view_fields.map((view) => ownMember(view.fields, field)), ].flatMap((value) => value !== undefined && value !== null && isStringArray(value) ? value diff --git a/src/domain/analysisView/environmentStateWriteStamps.test.ts b/src/domain/analysisView/environmentStateWriteStamps.test.ts index 92e90cd27a..8e460f3ba4 100644 --- a/src/domain/analysisView/environmentStateWriteStamps.test.ts +++ b/src/domain/analysisView/environmentStateWriteStamps.test.ts @@ -630,3 +630,20 @@ it("does not explain nested opaque-token references as independent generation", expect(update.unexplained).toContain("version"); } }); + +it.each(["__proto__", "constructor", "toString"])( + "treats an absent %s stamp field as missing JSON data", + (field) => { + const rule: FieldRule = { + rule: "derived", + field, + member: "change", + from: null, + value: 1, + }; + expect(stampProblem([rule], {}, { change: true })).toBeUndefined(); + expect( + stampProblem([rule], Object.fromEntries([[field, 7]]), { change: true }), + ).toContain("accepted only"); + }, +); diff --git a/src/domain/analysisView/environmentStateWriteStamps.ts b/src/domain/analysisView/environmentStateWriteStamps.ts index 09a1b50f9d..000b72583d 100644 --- a/src/domain/analysisView/environmentStateWriteStamps.ts +++ b/src/domain/analysisView/environmentStateWriteStamps.ts @@ -1,6 +1,8 @@ import type { JsonValue } from "../jsonValue.js"; import { err, ok, type Result } from "../result.js"; import { + ownMember, + setOwnMember, IDENTITY_NAMES, isObject, same, @@ -81,7 +83,8 @@ export const envelopeOf = ( ): { readonly member: string; readonly object: JsonObject } | undefined => { if (!isObject(body)) return undefined; const members = Object.keys(body); - const wrapped = members.length === 1 ? body[members[0]!] : undefined; + const wrapped = + members.length === 1 ? ownMember(body, members[0]!) : undefined; return Array.isArray(wrapped) && wrapped.length === 1 && isObject(wrapped[0]!) ? { member: members[0]!, object: wrapped[0] } : undefined; @@ -154,7 +157,7 @@ const sharedBesides = ( others: readonly JsonObject[], ): boolean => { const held = others.flatMap((other) => { - const value = other[field]; + const value = ownMember(other, field); return value !== undefined && isObject(value) ? [value] : []; }); return ( @@ -163,9 +166,9 @@ const sharedBesides = ( [...new Set([...Object.keys(other), ...Object.keys(object)])].every( (key) => members.includes(key) - ? readClock(other[key]) !== undefined && - !same(other[key], object[key]) - : same(other[key], object[key]), + ? readClock(ownMember(other, key)) !== undefined && + !same(ownMember(other, key), ownMember(object, key)) + : same(ownMember(other, key), ownMember(object, key)), ), ) ); @@ -179,7 +182,9 @@ const nestedRule = ( others: readonly JsonObject[], ): FieldRule | undefined => { const formats = new Set( - members.map((key) => JSON.stringify(readClock(object[key])?.format)), + members.map((key) => + JSON.stringify(readClock(ownMember(object, key))?.format), + ), ); return members.length > 0 && formats.size === 1 && @@ -188,7 +193,7 @@ const nestedRule = ( field, rule: "nested-clock", members: [...members], - format: readClock(object[members[0]!])!.format, + format: readClock(ownMember(object, members[0]!))!.format, } : undefined; }; @@ -207,7 +212,7 @@ export const nestedClockRule = ( return undefined; const changed = [ ...new Set([...Object.keys(before), ...Object.keys(after)]), - ].filter((key) => !same(before[key], after[key])); + ].filter((key) => !same(ownMember(before, key), ownMember(after, key))); return changed.every( (key) => Object.hasOwn(after, key) && isClockNear(after[key]!, evidence.startMs), @@ -232,7 +237,7 @@ export const createdNestedClock = ( ); // A clock read before the request, past a whole second's truncation, belongs to an entity made earlier. const stamped = members.every((key) => { - const ms = readClock(value[key])!.ms; + const ms = readClock(ownMember(value, key))!.ms; return ms >= startMs - TRUNCATION_MS && ms <= startMs + NESTED_WINDOW_MS; }); return stamped ? nestedRule(field, value, members, evidence.type) : undefined; @@ -257,13 +262,14 @@ export const createdToken = ( entity: JsonObject, type: readonly JsonObject[], ): "independent" | "linked" | undefined => { - const value = entity[field]; + const value = ownMember(entity, field); if ( !isOpaqueToken(value) || type.length === 0 || !type.every( (member) => - member[field] !== value && isOpaqueToken(member[field], value), + ownMember(member, field) !== value && + isOpaqueToken(ownMember(member, field), value), ) ) return undefined; @@ -294,8 +300,8 @@ export const freshToken = ( evidence: ChangeEvidence, field: string, ): boolean => { - const before = evidence.before[field]; - const after = evidence.after[field]; + const before = ownMember(evidence.before, field); + const after = ownMember(evidence.after, field); return ( isOpaqueToken(after, before) && isOpaqueToken(before) && @@ -337,7 +343,7 @@ export const stampRule = ( evidence: ChangeEvidence, field: string, ): FieldRule | undefined => { - const after = evidence.after[field]; + const after = ownMember(evidence.after, field); const creator = creatorOf(field); if (typeof after !== "string" || after.length === 0 || creator === undefined) return undefined; @@ -395,7 +401,7 @@ export const derivedRule = ( evidence: ChangeEvidence, field: string, ): FieldRule | undefined => { - const after = evidence.after[field]; + const after = ownMember(evidence.after, field); if (after === undefined || readClock(after) !== undefined) return undefined; const { body } = evidence.call; const unstored = Object.keys(body).filter( @@ -403,7 +409,8 @@ export const derivedRule = ( !Object.hasOwn(evidence.after, member) && !Object.hasOwn(evidence.before, member), ); - const text = unstored.length === 1 ? textOf(body[unstored[0]!]) : undefined; + const text = + unstored.length === 1 ? textOf(ownMember(body, unstored[0]!)) : undefined; const shown = typeof after === "string" ? after : JSON.stringify(after); return text !== undefined && text.length >= DERIVED_TEXT_MIN && @@ -412,7 +419,7 @@ export const derivedRule = ( field, rule: "derived", value: after, - from: evidence.before[field] ?? null, + from: ownMember(evidence.before, field) ?? null, member: unstored[0]!, } : undefined; @@ -428,14 +435,14 @@ export const stampProblem = ( if ( rule.rule === "derived" && Object.hasOwn(sent, rule.member) && - !same(before[rule.field] ?? null, rule.from) + !same(ownMember(before, rule.field) ?? null, rule.from) ) return `Member ${rule.member} is accepted only on an entity whose ${rule.field} is as the recorded call found it, since the application derives ${rule.field} from it.`; if (rule.rule === "held-clock" && !Object.hasOwn(sent, rule.field)) return `Member ${rule.field} must be sent with the value the entity holds, as the recorded call did.`; if ( rule.rule === "held-clock" && - !same(sent[rule.field], before[rule.field]) + !same(ownMember(sent, rule.field), ownMember(before, rule.field)) ) return `Member ${rule.field} must repeat the value the entity holds, as the recorded call did.`; } @@ -448,7 +455,7 @@ const nestedIdentities = (entity: JsonObject): Map> => { for (const [field, nested] of Object.entries(entity)) { if (!isObject(nested)) continue; const texts = IDENTITY_NAMES.flatMap((name) => { - const text = scalarText(nested[name]); + const text = scalarText(ownMember(nested, name)); return text === undefined ? [] : [text]; }); if (texts.length === 0) continue; @@ -480,7 +487,10 @@ const stateOf = ( Object.keys(entity).some( (field) => field !== member && - others.every((other) => unset(other[field]) !== unset(entity[field])), + others.every( + (other) => + unset(ownMember(other, field)) !== unset(ownMember(entity, field)), + ), ) ); }; @@ -527,11 +537,11 @@ export const restampedFields = ( const fields = { ...shown }; for (const rule of nested) { const read = storedRestamp( - shown[rule.field], - later?.object[rule.field], + ownMember(shown, rule.field), + ownMember(later?.object, rule.field), rule.members, ); - if (read !== undefined) fields[rule.field] = read; + if (read !== undefined) setOwnMember(fields, rule.field, read); } return fields; }; @@ -546,10 +556,13 @@ export const storedRestamp = ( if (!isObject(own) || !isObject(read)) return undefined; for (const key of new Set([...Object.keys(own), ...Object.keys(read)])) { if (!members.includes(key)) { - if (!same(own[key], read[key])) return undefined; + if (!same(ownMember(own, key), ownMember(read, key))) return undefined; continue; } - const [written, stored] = [readClock(own[key]), readClock(read[key])]; + const [written, stored] = [ + readClock(ownMember(own, key)), + readClock(ownMember(read, key)), + ]; if ( written === undefined || stored === undefined || diff --git a/src/domain/analysisView/environmentStateWrites.test.ts b/src/domain/analysisView/environmentStateWrites.test.ts index 002a10484e..2ff3fd6a4a 100644 --- a/src/domain/analysisView/environmentStateWrites.test.ts +++ b/src/domain/analysisView/environmentStateWrites.test.ts @@ -729,7 +729,6 @@ describe("unserved write plans", () => { stateOf(sending()), stateOf(sending([{ name: "Third Book" }])), recorded("application/x-www-form-urlencoded", "name=Third+Book"), - recorded("application/json", '{"__proto__":{},"name":"Third Book"}'), recorded( "application/json", '{"name":"Third Book","copies":12345678901234567890}', @@ -2602,3 +2601,26 @@ describe("recorded calls serving would refuse", () => { } }); }); + +it.each(["__proto__", "constructor"])( + "learns a confirmed update of an own %s field", + (field) => { + const initial = { + id: 1, + name: "One", + ...Object.fromEntries([[field, { label: "old" }]]), + }; + const body = Object.fromEntries([[field, { label: "stored" }]]); + const updated = { ...initial, ...body }; + const state = stateOf([ + ["GET", "/api/items", [initial, { id: 2, name: "Two" }]], + ["GET", "/api/items/1", initial], + ["PATCH", "/api/items/1", updated, 200, body], + ["GET", "/api/items/1", updated], + ]); + expect(planOf(state, "patch_items_by_id")).toMatchObject({ + served: true, + rules: expect.arrayContaining([{ rule: "echo", field }]), + }); + }, +); diff --git a/src/domain/jsonValue.test.ts b/src/domain/jsonValue.test.ts index cc6050ea56..a23a8ee949 100644 --- a/src/domain/jsonValue.test.ts +++ b/src/domain/jsonValue.test.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { jsonObjectSchema, + ownedJsonObjectSchema, jsonValueSchema, jsonValueValidationIssue, jsonValueValidationSteps, @@ -42,6 +43,23 @@ it("still parses only JSON values at runtime", () => { expect(jsonValueSchema.safeParse(value).success).toBe(false); }); +it("retains owned object identity while caller objects remain isolated", () => { + const source = JSON.parse('{"__proto__":{"name":"recorded"},"items":[1]}'); + const owned = ownedJsonObjectSchema.parse(source); + const copied = jsonObjectSchema.parse(source); + expect(owned).toBe(source); + expect(copied).not.toBe(source); + expect(copied["__proto__"]).not.toBe(source["__proto__"]); + expect(Object.hasOwn(owned, "__proto__")).toBe(true); + expect(JSON.stringify(owned)).toBe(JSON.stringify(copied)); + for (const input of [null, [], { value: undefined }, { value: Infinity }]) + expect(ownedJsonObjectSchema.safeParse(input).success).toBe(false); + for (const io of ["input", "output"] as const) + expect(z.toJSONSchema(ownedJsonObjectSchema, { io })).toEqual( + z.toJSONSchema(jsonObjectSchema, { io }), + ); +}); + it("rejects sparse arrays and enumerable symbol-keyed object members", () => { const sparse = new Array(1); const symbolKeyed = { [Symbol("caller data")]: "retain or reject" }; diff --git a/src/domain/jsonValue.ts b/src/domain/jsonValue.ts index c1629837ab..9a54dd1bf8 100644 --- a/src/domain/jsonValue.ts +++ b/src/domain/jsonValue.ts @@ -204,3 +204,8 @@ objectValidation._zod.toJSONSchema = objectProjection; /** JSON object boundary for caller-visible parameter maps. */ export const jsonObjectSchema = jsonValueSchema.pipe(objectValidation); jsonObjectSchema._zod.toJSONSchema = objectProjection; + +/** Validate an exclusively owned JSON object while retaining its existing tree. */ +export const ownedJsonObjectSchema = + ownedJsonValueSchema.pipe(objectValidation); +ownedJsonObjectSchema._zod.toJSONSchema = objectProjection; diff --git a/src/server/environment/serveEnvironmentState.test.ts b/src/server/environment/serveEnvironmentState.test.ts index 7ce720bdef..027330a32b 100644 --- a/src/server/environment/serveEnvironmentState.test.ts +++ b/src/server/environment/serveEnvironmentState.test.ts @@ -245,7 +245,7 @@ describe("state data plane", () => { }, }); expect(loginOf(plane.store)).toBe("probe"); - // Values parsing would alter are refused where they sit, never rounded or turned into a prototype. + // Numbers parsing or serialization would alter are refused where they sit. const body = JSON.stringify({ jsonrpc: "2.0", id: 2, @@ -256,7 +256,6 @@ describe("state data plane", () => { '"login":9007199254740993', // Exact in this spelling, but data/get would write it as digits it could not load again. '"login":1e20', - '"__proto__":{"polluted":true},"login":"renamed"', ]) { const reply = await plane.post( body.replace('"login":"renamed"', altered), @@ -267,12 +266,45 @@ describe("state data plane", () => { data: { path: expect.stringMatching(/\/fields$/u) }, }, }); - expect(JSON.stringify(reply.json)).not.toContain("polluted"); } expect(loginOf(plane.store)).toBe("probe"); }); }); +it("preserves arbitrary own data keys in inline and encoded file parts", async () => { + const plane = dataPlane(); + const initial = renamedDocument(); + const original = initial.rea_state.entities[0]!; + const members = JSON.parse( + '{"__proto__":{"constructor":0},"toString":false}', + ); + const entity = { ...original, fields: { ...original.fields, ...members } }; + const document = { + rea_state: { + ...initial.rea_state, + entities: [entity, ...initial.rea_state.entities.slice(1)], + }, + }; + for (const part of [ + { kind: "data", data: document }, + { + kind: "file", + file: { + bytes: Buffer.from(JSON.stringify(document)).toString("base64"), + }, + }, + ]) { + await expect(plane.add(part)).resolves.toMatchObject({ result: {} }); + const retained = plane.store.entity( + entity.type, + entity.collection, + entity.id, + )!; + expect(Object.hasOwn(retained.fields, "__proto__")).toBe(true); + expect(retained.fields).toEqual(entity.fields); + } +}); + describe("state data plane intake", () => { it("loads file parts from bytes and from a file uri", async () => { const plane = dataPlane(); diff --git a/src/server/environment/stateIntake.ts b/src/server/environment/stateIntake.ts index 86112345c7..c323fdf780 100644 --- a/src/server/environment/stateIntake.ts +++ b/src/server/environment/stateIntake.ts @@ -73,17 +73,16 @@ export const DEFAULT_STATE_INTAKE: StateIntake = { timeoutMs: STATE_FETCH_TIMEOUT_MS, }; -/** Stands in for a value that JSON parsing would change: a number without an exact native value, or a `__proto__` member. */ +/** Stands in for a number that native JSON parsing or serialization would change. */ const NOT_EXACT_JSON = Symbol("not exact JSON"); /** Mark values native parsing would change, so validation refuses them rather than storing an altered value. */ export const exactJsonReviver = ( - key: string, + _key: string, value: unknown, context?: { readonly source?: string }, ): unknown => - key === "__proto__" || - (typeof value === "number" && !isExactJsonNumber(value, context?.source)) + typeof value === "number" && !isExactJsonNumber(value, context?.source) ? NOT_EXACT_JSON : value; diff --git a/tests/boundary/mcp/environmentServeMcp.test.ts b/tests/boundary/mcp/environmentServeMcp.test.ts index 4215346a7a..83831a6496 100644 --- a/tests/boundary/mcp/environmentServeMcp.test.ts +++ b/tests/boundary/mcp/environmentServeMcp.test.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { timedCaptureEvidence } from "../../fixtures/analysisView.js"; +import { jsonObjectSchema } from "../../../src/domain/jsonValue.js"; import { Client, @@ -57,6 +58,36 @@ const connect = async (url: URL) => { return client; }; +processTest( + "replays recorded own JSON keys without interpreting inherited members as fields", + async ({ processes }) => { + const body = JSON.parse( + '{"id":1,"__proto__":{"source":"recorded"},"nested":{"constructor":null,"__proto__":[false,0,""]}}', + ); + const fixture = environmentStateServeFixture( + timedCaptureEvidence([["GET", "/api/books/1", body]]), + ); + const started = await serveReplayOnly(processes, {}, fixture); + const client = await connect(started.url); + try { + const response = await client.callTool({ + name: "get_books_by_id", + arguments: { id: "1" }, + }); + expect(response.isError).toBeFalsy(); + expect(response.structuredContent).toMatchObject({ body }); + const replayed = jsonObjectSchema.parse( + jsonObjectSchema.parse(response.structuredContent).body, + ); + expect(Object.hasOwn(replayed, "__proto__")).toBe(true); + expect(Object.hasOwn(replayed, "constructor")).toBe(false); + } finally { + await client.close(); + } + }, + HTTP_BOUNDARY_TIMEOUT_MS, +); + processTest( "validates repeated query arrays through the advertised MCP contract", async ({ processes }) => { diff --git a/tests/boundary/mcp/environmentServeWritesMcp.test.ts b/tests/boundary/mcp/environmentServeWritesMcp.test.ts index 46e2973e37..c6c9fdfcb0 100644 --- a/tests/boundary/mcp/environmentServeWritesMcp.test.ts +++ b/tests/boundary/mcp/environmentServeWritesMcp.test.ts @@ -32,12 +32,137 @@ import { unsupportedInputLiteralSchemaError } from "../../fixtures/mcpClientSche import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { startListeningProcess } from "../../support/mcp/streamableHttpProcess.js"; import { processTest } from "../../support/process/processFixture.js"; +import { + jsonObjectSchema, + type JsonValue, +} from "../../../src/domain/jsonValue.js"; const HTTP_BOUNDARY_TIMEOUT_MS = 90_000; /** The book the first served create of "Boundary Book" makes: one second past the served clock's start. */ const BOUNDARY_BOOK = book(3, "Boundary Book", bookClock(13)); +processTest( + "preserves own JSON keys through capture, state reload and computed writes", + async ({ processes }) => { + const nested = JSON.parse( + '{"__proto__":{"recorded":true},"constructor":null,"values":[false,0,""]}', + ) as JsonValue; + const captured = (id: number, name: string, updated = bookClock(id)) => { + const { slug, ...fields } = book(id, name, bookClock(id), updated); + if (typeof slug !== "string") throw new Error("Fixture book has no slug"); + return { ...fields, ["__proto__"]: slug, nested }; + }; + const first = captured(1, "First Book"); + const second = captured(2, "Second Book"); + const third = captured(3, "Third Book", bookClock(3)); + const renamed = captured(1, "Renamed Book", bookClock(5)); + const rows: readonly TimedCaptureRow[] = [ + ["GET", "/api/books", [first, second]], + ["GET", "/api/books/1", first], + ["GET", "/api/books/2", second], + ["POST", "/api/books", third, 201, { name: "Third Book" }], + ["GET", "/api/books/3", third], + ["PATCH", "/api/books/1", renamed, 200, { name: "Renamed Book" }], + ["GET", "/api/books/1", renamed], + ]; + const fixture = environmentStateServeFixture(timedCaptureEvidence(rows)); + expect(fixture.state.normalized_result).toMatchObject({ + entities: expect.arrayContaining([ + expect.objectContaining({ id: "1", fields: { ...first } }), + ]), + }); + const root = await createTestTempDirectory("rea-own-keys-state-mcp-"); + const files = await writeEnvironmentServeFiles(root, fixture); + const started = await startListeningProcess(processes, [ + "environment-serve", + files.manifestPath, + "--evidence", + files.capturePath, + "--port", + "0", + ]); + type StateDocument = { + rea_state: { + entities: Array<{ id: string; fields: Record }>; + [key: string]: unknown; + }; + }; + const rpc = async (method: string, params?: unknown) => + ( + await fetch(new URL("/agentenv", started.url.origin), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), + }) + ).json() as Promise<{ + result?: { parts?: Array<{ data: StateDocument }> }; + error?: { code: number }; + }>; + const document = (await rpc("data/get")).result?.parts?.[0]?.data; + if (document === undefined) + throw new Error("Missing served state document"); + const held = document.rea_state.entities.find(({ id }) => id === "1"); + if (held === undefined) throw new Error("Missing initial book"); + expect(Object.hasOwn(held.fields, "__proto__")).toBe(true); + expect(held.fields.nested).toEqual(nested); + expect(Object.hasOwn(held.fields, "constructor")).toBe(false); + await expect( + rpc("data/add", { parts: [{ kind: "data", data: document }] }), + ).resolves.toMatchObject({ result: {} }); + const reloaded = (await rpc("data/get")).result?.parts?.[0]?.data; + expect(reloaded).toEqual(document); + const invalid = { + rea_state: { + ...document.rea_state, + ["__proto__"]: "unexpected structural field", + }, + }; + await expect( + rpc("data/add", { parts: [{ kind: "data", data: invalid }] }), + ).resolves.toMatchObject({ error: { code: -32602 } }); + delete held.fields["__proto__"]; + await expect( + rpc("data/add", { parts: [{ kind: "data", data: document }] }), + ).resolves.toMatchObject({ result: {} }); + const client = new Client({ name: "own-keys-writes", version: "1" }); + await client.connect(new StreamableHTTPClientTransport(started.url)); + try { + for (const [tool, args, name, id] of [ + [ + "patch_books_by_id", + { id: "1", body: { name: "Updated Book" } }, + "Updated Book", + "1", + ], + ["post_books", { body: { name: "Created Book" } }, "Created Book", "3"], + ] as const) { + const written = await client.callTool({ name: tool, arguments: args }); + expect(written.isError).toBeFalsy(); + const expected = { + name, + ["__proto__"]: name.toLowerCase().replaceAll(" ", "-"), + nested, + }; + expect(written.structuredContent).toMatchObject({ body: expected }); + const read = await client.callTool({ + name: "get_books_by_id", + arguments: { id }, + }); + expect(read.structuredContent).toMatchObject({ body: expected }); + const body = jsonObjectSchema.parse( + jsonObjectSchema.parse(read.structuredContent).body, + ); + expect(Object.hasOwn(body, "__proto__")).toBe(true); + expect(Object.hasOwn(body, "constructor")).toBe(false); + } + } finally { + await client.close(); + } + }, + HTTP_BOUNDARY_TIMEOUT_MS, +); + processTest( "keeps create response clocks separate from enriched item reads through the SDK", async ({ processes }) => {