diff --git a/changelog.d/20261010_lab-8754.md b/changelog.d/20261010_lab-8754.md
new file mode 100644
index 0000000..95dd451
--- /dev/null
+++ b/changelog.d/20261010_lab-8754.md
@@ -0,0 +1,10 @@
+### Conformance — the interop cross-check rejects Symbol-keyed object properties (LAB-8754)
+
+- `tools/interop-crosscheck.mjs` rejects a plain object with an own enumerable Symbol-keyed
+ property as a non-string map key ([IOP-13](spec/interop-mode.md#iop-13)), wherever it sits.
+ Before, `Object.keys` skipped the Symbol key and the object encoded as if it were absent,
+ which [IOP-8](spec/interop-mode.md#iop-8) forbids. A non-enumerable Symbol key is ignored,
+ as a non-enumerable string key already was. The new `nonStringKeySelftest` pins both.
+ Every published vector verifies with unchanged output.
+- `conformance/requirements.json`: IOP-13 cites the new self-test, and its gap note no longer
+ says a JavaScript object has string keys only. `conformance/coverage.md` is regenerated.
diff --git a/conformance/coverage.md b/conformance/coverage.md
index 3df2cf5..3073c10 100644
--- a/conformance/coverage.md
+++ b/conformance/coverage.md
@@ -169,7 +169,7 @@ Marked not-a-requirement in `spec/intent-presets.md`:
| [IOP-10](../spec/interop-mode.md#iop-10) | *The Interop Data Model*: UTF-8 bytes of the string as given. No Unicode normalization (no NFC/NFD) is applied. Strings **MUST** be well-formed Unicode scalar sequences — an unpaired surrogate MUST be rejected, never replacement-encoded (JS … | `tools/interop-reference.py:_surrogate_selftest`
`tools/interop-crosscheck.mjs:loneSurrogateSelftest`
**Gap:** The self-tests reject a lone high or low surrogate as an argument, in a map key, in a nested value and in a set, but only in the reference tools: portable JSON cannot carry a lone surrogate, so no vector reaches an SDK. | uncovered | uncovered | uncovered |
| [IOP-11](../spec/interop-mode.md#iop-11) | *The Interop Data Model*: … NFC/NFD) is applied. Strings MUST be well-formed Unicode scalar sequences — an unpaired surrogate **MUST** be rejected, never replacement-encoded (JS `Buffer`/`TextEncoder` silently emit U+FFFD, so the TS SDK MUST check … | `tools/interop-reference.py:_surrogate_selftest`
`tools/interop-crosscheck.mjs:loneSurrogateSelftest`
**Gap:** The self-tests reject a lone high or low surrogate as an argument, in a map key, in a nested value and in a set, but only in the reference tools: portable JSON cannot carry a lone surrogate, so no vector reaches an SDK. | uncovered | uncovered | uncovered |
| [IOP-12](../spec/interop-mode.md#iop-12) | *The Interop Data Model*: … rejected, never replacement-encoded (JS `Buffer`/`TextEncoder` silently emit U+FFFD, so the TS SDK **MUST** check `String.prototype.isWellFormed()` first; Rust `String` is immune by construction; Python raises on encode). … | Binds cachekit-ts only.
`tools/interop-crosscheck.mjs:loneSurrogateSelftest`
**Gap:** The self-test exercises the reference cross-check, not cachekit-ts; portable JSON cannot carry a lone surrogate, so only the SDK's own suite can test it. | n/a | uncovered | n/a |
-| [IOP-13](../spec/interop-mode.md#iop-13) | *The Interop Data Model*: Keys **MUST** be strings (non-string key → error). Keys sorted by Unicode code point order at every nesting level; values normalized … | `interop-mode.json`: `map_key_proto`
`tools/interop-reference.py:_non_string_key_selftest`
**Gap:** Portable JSON object keys are always strings, so no vector carries a non-string key and only the Python reference's self-test rejects one; map_key_proto pins only that a string key such as __proto__ survives normalization. JavaScript objects have string keys only, and the cross-check rejects a Map as outside the data model (IOP-8). | partial (gap) | partial (gap) | partial (gap) |
+| [IOP-13](../spec/interop-mode.md#iop-13) | *The Interop Data Model*: Keys **MUST** be strings (non-string key → error). Keys sorted by Unicode code point order at every nesting level; values normalized … | `interop-mode.json`: `map_key_proto`
`tools/interop-reference.py:_non_string_key_selftest`
`tools/interop-crosscheck.mjs:nonStringKeySelftest`
**Gap:** Portable JSON object keys are always strings, so no vector carries a non-string key and only the reference tools' self-tests reject one; map_key_proto pins only that a string key such as __proto__ survives normalization. A JavaScript object can also carry Symbol keys: the cross-check rejects an own enumerable Symbol key, ignores a non-enumerable one as it ignores a non-enumerable string key, and rejects a Map as outside the data model (IOP-8). | partial (gap) | partial (gap) | partial (gap) |
| [IOP-14](../spec/interop-mode.md#iop-14) | *The Interop Data Model*: … sorts *after* `"𐀀"` (U+10000) in UTF-16 order but *before* it in code-point order. The TS SDK **MUST** sort by comparing UTF-8-encoded key bytes (or by code point), not with `Array.prototype.sort()`'s default comparator. … | Binds cachekit-ts only.
`interop-mode.json`: `map_key_sort_supplementary`, `map_key_sort_ascii`, `nested_map_recursive_sort`, `map_key_sort_case_across_letters`, `map_key_sort_punctuation`, `map_key_sort_shorter_key_later`, `map_key_sort_supplementary_shorter` | n/a | covered | n/a |
| [IOP-15](../spec/interop-mode.md#iop-15) | *Number canonicalization*: … keep the full `[-2^63, 2^64-1]` range (snowflake IDs work). In JavaScript, integers above 2⁵³ **MUST** be handled as `BigInt`; the SDK MUST error on a non-integral-safe `Number` rather than silently rounding. | Binds cachekit-ts only.
`interop-mode.json`: `uint64_max`, `int64_min`, `int_beyond_float64_precision`, `small_int_as_bigint` | n/a | covered | n/a |
| [IOP-16](../spec/interop-mode.md#iop-16) | *Number canonicalization*: … range (snowflake IDs work). In JavaScript, integers above 2⁵³ MUST be handled as `BigInt`; the SDK **MUST** error on a non-integral-safe `Number` rather than silently rounding. | Binds cachekit-ts only.
`tools/interop-crosscheck.mjs:unsafeNumberSelftest`
**Gap:** The self-test exercises the reference cross-check, not cachekit-ts: Python and Rust read a bare JSON integer beyond 2^53 exactly, so no shared vector can carry the unsafe Number only JavaScript would see. | n/a | uncovered | n/a |
diff --git a/conformance/requirements.json b/conformance/requirements.json
index ce3f4dc..af6b6c8 100644
--- a/conformance/requirements.json
+++ b/conformance/requirements.json
@@ -1127,9 +1127,10 @@
"interop-mode.json:map_key_proto"
],
"tests": [
- "tools/interop-reference.py:_non_string_key_selftest"
+ "tools/interop-reference.py:_non_string_key_selftest",
+ "tools/interop-crosscheck.mjs:nonStringKeySelftest"
],
- "gap": "Portable JSON object keys are always strings, so no vector carries a non-string key and only the Python reference's self-test rejects one; map_key_proto pins only that a string key such as __proto__ survives normalization. JavaScript objects have string keys only, and the cross-check rejects a Map as outside the data model (IOP-8)."
+ "gap": "Portable JSON object keys are always strings, so no vector carries a non-string key and only the reference tools' self-tests reject one; map_key_proto pins only that a string key such as __proto__ survives normalization. A JavaScript object can also carry Symbol keys: the cross-check rejects an own enumerable Symbol key, ignores a non-enumerable one as it ignores a non-enumerable string key, and rejects a Map as outside the data model (IOP-8)."
},
"IOP-14": {
"section": "The Interop Data Model",
diff --git a/tools/interop-crosscheck.mjs b/tools/interop-crosscheck.mjs
index 261f5d4..183ae03 100644
--- a/tools/interop-crosscheck.mjs
+++ b/tools/interop-crosscheck.mjs
@@ -222,7 +222,13 @@ function encodeCanonical(v, chunks, { collapseFloats }) {
// outside the data model and falls through to the error below, never to an
// empty map. Sort keys by UTF-8 byte order (== Unicode code point order). JS default
// string sort compares UTF-16 code units and gets supplementary-plane
- // characters WRONG — compare encoded bytes instead.
+ // characters WRONG — compare encoded bytes instead. Its data is its own
+ // enumerable properties, and an enumerable Symbol key is a non-string map key.
+ // propertyIsEnumerable goes through Object.prototype: a null-prototype object
+ // has none, and the message names no Symbol, since interpolating one throws.
+ if (Object.getOwnPropertySymbols(v).some((s) => Object.prototype.propertyIsEnumerable.call(v, s))) {
+ throw new OutOfModelError("map keys must be strings: object has an enumerable Symbol key");
+ }
const keys = Object.keys(v).sort((a, b) =>
Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")),
);
@@ -566,7 +572,7 @@ function loneSurrogateSelftest() {
}
// A value outside the data model is rejected wherever it sits, never encoded
-// as an empty map: in JavaScript a non-string map key needs a Map.
+// as an empty map.
function outOfModelSelftest() {
class Point {}
for (const [label, args] of [
@@ -582,6 +588,26 @@ function outOfModelSelftest() {
}
}
+// Map keys must be strings, at every nesting level, as in the Python reference.
+// A JavaScript object can carry Symbol keys: an own enumerable one is rejected, and
+// a non-enumerable one is ignored, as Object.keys ignores a non-enumerable string key.
+function nonStringKeySelftest() {
+ const sym = Symbol("k");
+ const nullProto = Object.assign(Object.create(null), { [sym]: 1n });
+ for (const [label, args] of [
+ ["Symbol key", [{ [sym]: 1n, a: 2n }]],
+ ["Symbol key on a null-prototype object", [nullProto]],
+ ["Symbol key in a nested map", [{ ok: { [sym]: 1n } }]],
+ ["Symbol key in a list", [[1n, { [sym]: 1n }]]],
+ ["Symbol key in a set element", [new TaggedSet([{ [sym]: 1n }])]],
+ ]) {
+ expectRejected("nonStringKeySelftest", label, args);
+ }
+ const hidden = Object.defineProperty({ a: 2n }, sym, { value: 1n, enumerable: false });
+ check("nonStringKeySelftest", "non-enumerable Symbol key", "9181a16102", encodeToBuffer([hidden], { collapseFloats: true }).toString("hex"));
+ check("nonStringKeySelftest", "string key", "9181a131a161", encodeToBuffer([{ "1": "a" }], { collapseFloats: true }).toString("hex"));
+}
+
// A bare Number that is not a safe integer has already lost precision, so it is
// rejected, never rounded; the largest safe integer still encodes like its BigInt.
function unsafeNumberSelftest() {
@@ -606,6 +632,7 @@ function unsafeNumberSelftest() {
loneSurrogateSelftest();
outOfModelSelftest();
+nonStringKeySelftest();
unsafeNumberSelftest();
if (failures > 0) {