diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
index 0cd154e190..ae5592ef01 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
-2026-07-20-unified-json-value-schema-dsl.md: 94c3f5aa5fcb84abddc58e8fd298188b3284f7ea
-2026-07-20-unified-json-value-schema-dsl.zh.md: d8362c2c9987689fbd812b6c16aae815f56062e1
+2026-07-20-unified-json-value-schema-dsl.md: 6a3dc21a7ae76ba8f84f5b3edf8306285a02a683
+2026-07-20-unified-json-value-schema-dsl.zh.md: 77ac599e6713563528d0382dfecff83071488930
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
index 94c3f5aa5f..6a3dc21a7a 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
@@ -12,7 +12,7 @@ Tool parameters used a small author DSL while subagent/workflow structured outpu
`dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node.
-An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue` and `InferArgs
` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Intrinsic plain Object and Array containers remain plain across JavaScript realms; subclasses remain exotic.
+An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue` and `InferArgs
` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Intrinsic plain Object and Array containers remain plain across JavaScript realms; subclasses remain exotic. Validation and snapshot traversal are iterative, so valid nesting is limited by available memory rather than the JavaScript call stack.
Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler.
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
index d8362c2c99..77ac599e67 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
@@ -12,7 +12,7 @@ Status: implemented
`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
-显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs
` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。
+显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs
` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。校验和快照遍历均以迭代方式执行,因此合法嵌套的深度上限由可用内存决定,而非 JavaScript 调用栈。
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
diff --git a/packages/cordis/tool-cordis/src/guard.ts b/packages/cordis/tool-cordis/src/guard.ts
index e8eb19e524..2ebba0fbd8 100644
--- a/packages/cordis/tool-cordis/src/guard.ts
+++ b/packages/cordis/tool-cordis/src/guard.ts
@@ -29,9 +29,34 @@ type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
function isPlainRecord(value: unknown): value is Record {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const prototype: unknown = Object.getPrototypeOf(value)
- return prototype === null || Object.getPrototypeOf(prototype) === null
+ return prototype === null
+ || typeof prototype === 'object'
+ && Object.getPrototypeOf(prototype) === null
+ && hasIntrinsicConstructor(prototype, 'Object')
}
+/* jscpd:ignore-start -- this VM boundary mirrors the session-owned realm-safe intrinsic test */
+/** Whether a realm-owned intrinsic prototype names and points back to its constructor. */
+function hasIntrinsicConstructor(prototype: object, name: 'Array' | 'Object'): boolean {
+ const descriptor = Object.getOwnPropertyDescriptor(prototype, 'constructor')
+ const constructor: unknown = descriptor?.value
+ return typeof constructor === 'function'
+ && constructor.name === name
+ && constructor.prototype === prototype
+}
+
+/** Whether an array uses one realm's intrinsic Array prototype rather than a subclass. */
+function hasPlainArrayPrototype(value: unknown[]): boolean {
+ const prototype: unknown = Object.getPrototypeOf(value)
+ if (!Array.isArray(prototype) || !hasIntrinsicConstructor(prototype, 'Array')) return false
+ const objectPrototype: unknown = Object.getPrototypeOf(prototype)
+ return typeof objectPrototype === 'object'
+ && objectPrototype !== null
+ && Object.getPrototypeOf(objectPrototype) === null
+ && hasIntrinsicConstructor(objectPrototype, 'Object')
+}
+/* jscpd:ignore-end */
+
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
function cloneJson(value: unknown, path: string, seen = new Set()): unknown {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
@@ -44,7 +69,7 @@ function cloneJson(value: unknown, path: string, seen = new Set()): unkn
seen.add(value)
try {
if (Array.isArray(value)) {
- if (Reflect.ownKeys(value).length !== value.length + 1) {
+ if (!hasPlainArrayPrototype(value) || Reflect.ownKeys(value).length !== value.length + 1) {
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
}
const output: unknown[] = []
diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts
index 64301f2172..22df219b1b 100644
--- a/packages/cordis/tool-cordis/tests/mount.spec.ts
+++ b/packages/cordis/tool-cordis/tests/mount.spec.ts
@@ -348,6 +348,7 @@ describe('cordis_mount', () => {
['parameters: { value: { type: \'json\', default: Object.defineProperty({}, \'hidden\', { value: true }) } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: { [Symbol(\'hidden\')]: true } } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new (class DefaultValue { constructor() { this.ok = true } })() } }', 'parameters.value.default must be lossless JSON data'],
+ ['parameters: { value: { type: \'json\', default: new (class DefaultList extends Array {})() } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'],
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
diff --git a/packages/core/session/README.md b/packages/core/session/README.md
index bba2a21e06..e01e8072c3 100644
--- a/packages/core/session/README.md
+++ b/packages/core/session/README.md
@@ -46,7 +46,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
### Lossless JSON utilities
-Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` recursively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization.
+Durable values need one accepted representation, not a check followed by a second read. `isJsonValue(value)` is the boolean predicate; `snapshotJsonValue(value)` iteratively validates and copies a plain value in one pass, returning `undefined` for invalid input and propagating a throwing getter. The snapshot helper accepts finite JSON numbers except `-0` (JSON rewrites it to `0`), dense ordinary arrays, and plain or null-prototype objects; it rejects cycles, unsupported scalars, and exotic prototypes before normalization without imposing a call-stack depth limit.
### Surface types
diff --git a/packages/core/session/src/json.ts b/packages/core/session/src/json.ts
index f7609a1f46..e1452457fd 100644
--- a/packages/core/session/src/json.ts
+++ b/packages/core/session/src/json.ts
@@ -50,79 +50,127 @@ function enumerableStringKeys(value: object): string[] | undefined {
return keys as string[]
}
+type SnapshotDestination =
+ | { kind: 'root' }
+ | { kind: 'array'; target: JsonValue[]; index: number }
+ | { kind: 'object'; target: { [key: string]: JsonValue }; key: string }
+
+type JsonWalkTask =
+ | { kind: 'visit'; value: unknown; destination?: SnapshotDestination }
+ | { kind: 'array-item'; source: unknown[]; index: number; target?: JsonValue[] }
+ | { kind: 'object-property'; source: Record; key: string; target?: { [key: string]: JsonValue } }
+ | { kind: 'leave'; source: object }
+
+/** Validate lossless JSON iteratively, optionally materializing a detached snapshot. */
+function walkJsonValue(value: unknown, detach: boolean): JsonValue | true | undefined {
+ const ancestors = new Set()
+ let root: JsonValue | undefined
+ const assign = (destination: SnapshotDestination | undefined, item: JsonValue): void => {
+ if (destination === undefined) return
+ if (destination.kind === 'root') {
+ root = item
+ } else if (destination.kind === 'array') {
+ destination.target[destination.index] = item
+ } else {
+ Object.defineProperty(destination.target, destination.key, {
+ value: item,
+ enumerable: true,
+ configurable: true,
+ writable: true,
+ })
+ }
+ }
+
+ const tasks: JsonWalkTask[] = [{
+ kind: 'visit',
+ value,
+ ...(detach ? { destination: { kind: 'root' } as const } : {}),
+ }]
+ for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
+ if (task.kind === 'leave') {
+ ancestors.delete(task.source)
+ continue
+ }
+ if (task.kind === 'array-item') {
+ if (!Object.prototype.hasOwnProperty.call(task.source, task.index)) return undefined
+ tasks.push({
+ kind: 'visit',
+ value: task.source[task.index],
+ ...(task.target === undefined ? {} : { destination: { kind: 'array', target: task.target, index: task.index } as const }),
+ })
+ continue
+ }
+ if (task.kind === 'object-property') {
+ tasks.push({
+ kind: 'visit',
+ value: task.source[task.key],
+ ...(task.target === undefined ? {} : { destination: { kind: 'object', target: task.target, key: task.key } as const }),
+ })
+ continue
+ }
+
+ const current = task.value
+ if (current === null) {
+ assign(task.destination, null)
+ continue
+ }
+ if (typeof current === 'boolean' || typeof current === 'string') {
+ assign(task.destination, current)
+ continue
+ }
+ if (typeof current === 'number') {
+ if (!Number.isFinite(current) || Object.is(current, -0)) return undefined
+ assign(task.destination, current)
+ continue
+ }
+ if (typeof current !== 'object') return undefined
+ if (ancestors.has(current)) return undefined
+
+ if (Array.isArray(current)) {
+ if (!hasPlainArrayPrototype(current)) return undefined
+ const length = current.length
+ if (Reflect.ownKeys(current).length !== length + 1) return undefined
+ const target = detach ? [] as JsonValue[] : undefined
+ if (target !== undefined) assign(task.destination, target)
+ ancestors.add(current)
+ tasks.push({ kind: 'leave', source: current })
+ for (let index = length - 1; index >= 0; index--) {
+ tasks.push({ kind: 'array-item', source: current, index, ...(target === undefined ? {} : { target }) })
+ }
+ continue
+ }
+
+ if (!hasPlainObjectPrototype(current)) return undefined
+ const keys = enumerableStringKeys(current)
+ if (keys === undefined) return undefined
+ const target = detach ? {} as { [key: string]: JsonValue } : undefined
+ if (target !== undefined) assign(task.destination, target)
+ ancestors.add(current)
+ tasks.push({ kind: 'leave', source: current })
+ for (let index = keys.length - 1; index >= 0; index--) {
+ const key = keys[index]
+ /* v8 ignore next -- the loop is bounded by the captured key count. */
+ if (key === undefined) return undefined
+ tasks.push({ kind: 'object-property', source: current as Record, key, ...(target === undefined ? {} : { target }) })
+ }
+ }
+ return detach ? root : true
+}
+
/**
* Validate and detach lossless JSON in one read per property, so a stateful
- * getter cannot change between validation and copying. Accepts ordinary arrays,
- * plain or null-prototype objects, and JSON scalars; rejects sparse, cyclic,
- * exotic, negative-zero, and non-finite values. Getter throws propagate.
+ * getter cannot change between validation and copying. Traversal is iterative,
+ * so valid nesting is bounded by available memory rather than the JavaScript
+ * call stack. Accepts ordinary arrays, plain or null-prototype objects, and JSON
+ * scalars; rejects sparse, cyclic, exotic, negative-zero, and non-finite values.
+ * Getter throws propagate.
*
* @param value - the candidate value to validate and detach.
* @returns the detached snapshot, or `undefined` when the value is not
* losslessly JSON-serializable.
*/
export function snapshotJsonValue(value: T): T | undefined {
- const ancestors = new Set()
-
- const visit = (current: unknown): JsonValue | undefined => {
- if (current === null) return null
- switch (typeof current) {
- case 'boolean':
- case 'string':
- return current
- case 'number':
- return Number.isFinite(current) && !Object.is(current, -0) ? current : undefined
- case 'bigint':
- case 'function':
- case 'symbol':
- case 'undefined':
- return undefined
- case 'object':
- break
- }
-
- if (ancestors.has(current)) return undefined
- ancestors.add(current)
- try {
- if (Array.isArray(current)) {
- if (!hasPlainArrayPrototype(current)) return undefined
- const length = current.length
- // Every ordinary array owns `length`; dense indexed elements account
- // for the remaining keys. Anything else would be lost by JSON and by
- // structured clone, including symbols and non-enumerable properties.
- if (Reflect.ownKeys(current).length !== length + 1) return undefined
- const snapshot: JsonValue[] = []
- for (let index = 0; index < length; index++) {
- if (!Object.prototype.hasOwnProperty.call(current, index)) return undefined
- const item = visit(current[index])
- if (item === undefined) return undefined
- snapshot.push(item)
- }
- return snapshot
- }
-
- if (!hasPlainObjectPrototype(current)) return undefined
- const keys = enumerableStringKeys(current)
- if (keys === undefined) return undefined
- const snapshot: { [key: string]: JsonValue } = {}
- for (const key of keys) {
- const item = visit((current as Record)[key])
- if (item === undefined) return undefined
- // Define the key as data so a JSON field literally named "__proto__"
- // cannot mutate the snapshot's prototype through ordinary assignment.
- Object.defineProperty(snapshot, key, {
- value: item,
- enumerable: true,
- configurable: true,
- writable: true,
- })
- }
- return snapshot
- } finally {
- ancestors.delete(current)
- }
- }
-
- return visit(value) as T | undefined
+ return walkJsonValue(value, true) as T | undefined
}
/**
@@ -130,46 +178,8 @@ export function snapshotJsonValue(value: T): T | undefined {
* detaching it. Only own enumerable string properties participate; `toJSON`
* is ignored and getters run, so persistence boundaries use the snapshotter.
* @param value - the candidate event data to test.
- * @param seen - current recursion path; callers omit it.
* @returns whether `value` survives JSON round-trip losslessly.
*/
-export function isJsonValue(value: unknown, seen: Set = new Set()): boolean {
- if (value === null) return true
- switch (typeof value) {
- case 'boolean':
- case 'string':
- return true
- case 'number':
- return Number.isFinite(value) && !Object.is(value, -0)
- case 'bigint':
- case 'function':
- case 'symbol':
- case 'undefined':
- return false
- case 'object':
- break // handled below
- }
- // object
- if (seen.has(value)) return false // circular
- seen.add(value)
- try {
- if (Array.isArray(value)) {
- if (!hasPlainArrayPrototype(value)) return false
- if (Reflect.ownKeys(value).length !== value.length + 1) return false
- // Reject sparse arrays: a hole is skipped by `every`/`forEach` but
- // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
- // lossily. Require every index 0..length-1 to be an OWN property.
- for (let i = 0; i < value.length; i++) {
- if (!Object.prototype.hasOwnProperty.call(value, i)) return false
- if (!isJsonValue(value[i], seen)) return false
- }
- return true
- }
- // Plain object only (reject Map/Set/Date/class instances).
- if (!hasPlainObjectPrototype(value)) return false
- const keys = enumerableStringKeys(value)
- return keys !== undefined && keys.every(key => isJsonValue((value as Record)[key], seen))
- } finally {
- seen.delete(value)
- }
+export function isJsonValue(value: unknown): boolean {
+ return walkJsonValue(value, false) === true
}
diff --git a/packages/core/session/tests/json.spec.ts b/packages/core/session/tests/json.spec.ts
index 90b3ec6928..e8680142f3 100644
--- a/packages/core/session/tests/json.spec.ts
+++ b/packages/core/session/tests/json.spec.ts
@@ -80,6 +80,19 @@ describe('snapshotJsonValue', () => {
expect(arrayReads).toBe(1)
})
+ it('accepts deeply nested valid JSON without using the JavaScript call stack', () => {
+ let value: JsonValue = 'leaf'
+ for (let depth = 0; depth < 5_000; depth++) value = [value]
+
+ expect(isJsonValue(value)).toBe(true)
+ let cursor: JsonValue | undefined = snapshotJsonValue(value)
+ for (let depth = 0; depth < 5_000; depth++) {
+ expect(Array.isArray(cursor)).toBe(true)
+ cursor = Array.isArray(cursor) ? cursor[0] : undefined
+ }
+ expect(cursor).toBe('leaf')
+ })
+
it('rejects exotic containers, sparse or decorated arrays, cycles, and invalid children', () => {
class ExoticObject {
readonly value = 1