TypeScript: a payload is one MessagePack value (bytes-after-ts) (#130)

This commit is contained in:
Libby 2026-11-12 06:02:51 +00:00
commit 66bea107d5
11 changed files with 229 additions and 25 deletions

View file

@ -24,10 +24,12 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
### Added
- `checkPayloadDepth` returns the offset where the first value ends, or `undefined` when the payload ends early (a header, a container or a str/bin/scalar body cut short; it was `void`); it still refuses only depth. The bytes-after refusal is internal (`checkPayloadIsOneValue`, not exported); no new public name.
- `CultCache.foreignRecords()`: the `{key, schemaId, schemaName, storedAt}` of every record the last pull carried and no registered type claims, sorted by key ordinal.
### Changed
- A document payload is exactly one MessagePack value (cultlib-gaps:ruling:trailing-bytes-refuse). `decodeDocumentPayload`, the one reader (store loads, `put`/`putEnvelope`, CultNet replication, the provider command), and the inspector refuse bytes after the first value with `document type "T" payload has N bytes after its first MessagePack value.`, counted from the walk's end and never walked or echoed; a payload that ends early stays the decoder's to refuse. The refused set is unchanged; only the text changed (it was `@msgpack`'s `RangeError` "Extra N of M byte(s)" for a plain document and the codec reader's "bytes after its value" for a codec document, and the inspector's preview no longer shows a tail), so code matching either text must match this one. `readWire` reads one value and no longer checks what follows. Stated limit: a definition with its own `formatter` is not walked, so whether its payload is one value is the formatter's. Implied bump: 0.16.0 (pre-1.0 minor, with the Breaking entries above).
- Opening a legacy (pre-v1) store is a pure read: payloads that were not bytes are normalized in memory and the
file is left as it was, so a codec that refuses the content cannot lose the old bytes. The next push, delete or
flush rewrites the file as v1, merging what the open read.

View file

@ -49,7 +49,9 @@ through `decodeDocumentPayload`, and when you need the bytes themselves (hashing
write) take them with `documentPayloadBytes`, the sanctioned escape.
- `decodeDocumentPayload(definition, payload)` reads a payload through the definition's formatter,
codec or plain map and then its schema. The definition is required.
codec or plain map and then its schema. The definition is required. A payload is exactly one
MessagePack value: bytes after the first are refused by count, naming the type, and never walked or
echoed (a payload that ends early is the decoder's to refuse).
- `encodeDocumentPayload(definition, value)` is the writer: the schema first, then the formatter,
codec or plain map.
- `documentPayloadFromBytes(bytes)` is where bytes enter (a record you validated, a store read, a
@ -327,7 +329,7 @@ If you do not supply one, `CultCacheTS` uses MessagePack plus Zod validation by
The 256-container nesting bound (`MAX_PAYLOAD_DEPTH`, refused with `CultCachePayloadDepthError`) covers the MessagePack the
library itself writes: the default format and a codec. A payload your formatter writes is opaque bytes in any format, so the
library does not walk it as MessagePack; its format and its bounds are your formatter's.
library does not walk it as MessagePack; its format, its bounds and whether it is one value are your formatter's.
Zod is no longer mandatory. Any schema object with a `parse(...)` function will
do, which means generated Ajv-backed JSON-schema contracts can feed the cache

View file

@ -1,6 +1,6 @@
import { decode } from "@msgpack/msgpack";
import { checkPayloadDepth } from "./payload-depth";
import { checkPayloadDepth, checkPayloadIsOneValue } from "./payload-depth";
import { type StoreFormat, isStoreSnapshot, refuseUnreadStoreBytes, requireStoreSlots, requireV1RecordSlots } from "./store-format";
export interface CultCacheInspection {
@ -235,12 +235,22 @@ function decodeLegacyEnvelope(value: unknown): InspectedRecord {
};
}
// The inspector's one payload read: the depth walk first, so a payload past the limit is refused before any decoder sees it.
// The inspector's one payload read: the depth walk first, so a payload past the limit is refused before any decoder sees it,
// and bytes after the first value are refused with the shared clause (the tail is never decoded or shown).
function decodeBoundedPayload(payload: Uint8Array): unknown {
checkPayloadDepth(payload);
checkPayloadIsOneValue(payload, "document payload");
return decode(payload);
}
// Where the first value ends (the whole payload when it ends early or cannot be walked): a preview never shows a tail.
function valueEnd(payload: Uint8Array): number {
try {
return checkPayloadDepth(payload) ?? payload.length;
} catch {
return payload.length;
}
}
function previewPayload(payload: Uint8Array): Pick<InspectedRecord, "payloadPreview" | "payloadDecodeError"> {
try {
return { payloadPreview: toJsonSafe(decodeBoundedPayload(payload)) };
@ -248,7 +258,7 @@ function previewPayload(payload: Uint8Array): Pick<InspectedRecord, "payloadPrev
return {
payloadPreview: {
bytes: payload.length,
hexPrefix: [...payload.slice(0, 32)]
hexPrefix: [...payload.slice(0, Math.min(32, valueEnd(payload)))]
.map((value) => value.toString(16).padStart(2, "0"))
.join(" "),
},

View file

@ -1,6 +1,6 @@
import type { NotedSlots } from "./codec";
import { decodeMarked, resolveDocumentFormatter } from "./document-formatter";
import { checkPayloadDepth } from "./payload-depth";
import { checkPayloadDepth, checkPayloadIsOneValue } from "./payload-depth";
import type {
AnyCultCacheDocumentDefinition,
CultCacheSchemaCatalogMember,
@ -129,7 +129,11 @@ export function decodeDocumentPayloadMarked<TDefinition extends AnyCultCacheDocu
payload: DocumentPayload,
): { value: CultCacheDocumentValue<TDefinition>; unreadSlots?: NotedSlots } {
const bytes = documentPayloadBytes(payload);
if (writesLibraryMessagePack(definition)) checkPayloadDepth(bytes, `document type "${definition.type}" payload`);
if (writesLibraryMessagePack(definition)) {
// A payload is exactly one MessagePack value: bytes after the first are refused by count before any formatter reads,
// so a tail is refused before any slot is marked.
checkPayloadIsOneValue(bytes, `document type "${definition.type}" payload`);
}
const { value: decoded, unreadSlots } = decodeMarked(definition, bytes);
const value = definition.schema.parse(decoded) as CultCacheDocumentValue<TDefinition>;
return unreadSlots ? { value, unreadSlots } : { value };

View file

@ -12,8 +12,9 @@ export class CultCachePayloadDepthError extends Error {
// Walks the headers of one MessagePack value iteratively (a count stack bounded by the limit, scalars skipped by length,
// nothing read) and throws at the first container past the limit. The message names the subject, the depth and the limit,
// never payload bytes. A payload that ends early is the reader's to refuse.
export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload"): void {
// never payload bytes. Returns the offset where the first value ends, or undefined when the payload ends early (the
// reader's to refuse); what follows that offset is never walked, and whether it may exist is the caller's rule.
export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload"): number | undefined {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const remaining = new Float64Array(MAX_PAYLOAD_DEPTH);
let depth = 0;
@ -33,12 +34,12 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload
else if (code <= 0x8f) { container = true; header = 1; children = 2 * (code & 0x0f); }
else if (code <= 0x9f) { container = true; header = 1; children = code & 0x0f; }
else if (code <= 0xbf) skip = 1 + (code & 0x1f);
else if (code === 0xc4 || code === 0xd9) { const n = width(1); if (n === undefined) return; skip = 2 + n; }
else if (code === 0xc5 || code === 0xda) { const n = width(2); if (n === undefined) return; skip = 3 + n; }
else if (code === 0xc6 || code === 0xdb) { const n = width(4); if (n === undefined) return; skip = 5 + n; }
else if (code === 0xc7) { const n = width(1); if (n === undefined) return; skip = 3 + n; }
else if (code === 0xc8) { const n = width(2); if (n === undefined) return; skip = 4 + n; }
else if (code === 0xc9) { const n = width(4); if (n === undefined) return; skip = 6 + n; }
else if (code === 0xc4 || code === 0xd9) { const n = width(1); if (n === undefined) return undefined; skip = 2 + n; }
else if (code === 0xc5 || code === 0xda) { const n = width(2); if (n === undefined) return undefined; skip = 3 + n; }
else if (code === 0xc6 || code === 0xdb) { const n = width(4); if (n === undefined) return undefined; skip = 5 + n; }
else if (code === 0xc7) { const n = width(1); if (n === undefined) return undefined; skip = 3 + n; }
else if (code === 0xc8) { const n = width(2); if (n === undefined) return undefined; skip = 4 + n; }
else if (code === 0xc9) { const n = width(4); if (n === undefined) return undefined; skip = 6 + n; }
else if (code === 0xca || code === 0xce || code === 0xd2) skip = 5;
else if (code === 0xcb || code === 0xcf || code === 0xd3) skip = 9;
else if (code === 0xcc || code === 0xd0) skip = 2;
@ -47,10 +48,10 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload
else if (code === 0xd6) skip = 6;
else if (code === 0xd7) skip = 10;
else if (code === 0xd8) skip = 18;
else if (code === 0xdc) { const n = width(2); if (n === undefined) return; container = true; header = 3; children = n; }
else if (code === 0xdd) { const n = width(4); if (n === undefined) return; container = true; header = 5; children = n; }
else if (code === 0xde) { const n = width(2); if (n === undefined) return; container = true; header = 3; children = 2 * n; }
else { const n = width(4); if (n === undefined) return; container = true; header = 5; children = 2 * n; }
else if (code === 0xdc) { const n = width(2); if (n === undefined) return undefined; container = true; header = 3; children = n; }
else if (code === 0xdd) { const n = width(4); if (n === undefined) return undefined; container = true; header = 5; children = n; }
else if (code === 0xde) { const n = width(2); if (n === undefined) return undefined; container = true; header = 3; children = 2 * n; }
else { const n = width(4); if (n === undefined) return undefined; container = true; header = 5; children = 2 * n; }
if (container) {
if (depth === MAX_PAYLOAD_DEPTH) throw new CultCachePayloadDepthError(MAX_PAYLOAD_DEPTH + 1, MAX_PAYLOAD_DEPTH, subject);
@ -63,8 +64,20 @@ export function checkPayloadDepth(bytes: Uint8Array, subject = "document payload
position += skip;
}
// A scalar whose declared length runs past the payload is an early end, not a value.
if (position > bytes.length) return undefined;
// One element is complete; close every container it was the last element of.
while (depth > 0 && --remaining[depth - 1] === 0) depth--;
if (depth === 0) return;
if (depth === 0) return position;
}
return undefined;
}
// The one rule that a payload is exactly one MessagePack value: the depth walk, then a refusal of bytes after the first
// value, by count, naming the subject, from the walk's end. The tail is never walked or echoed; a payload that ends early
// stays the decoder's to refuse. decodeDocumentPayload and the inspector speak this clause and no other.
export function checkPayloadIsOneValue(bytes: Uint8Array, subject: string): number | undefined {
const end = checkPayloadDepth(bytes, subject);
if (end !== undefined && end < bytes.length) throw new Error(`${subject} has ${bytes.length - end} bytes after its first MessagePack value.`);
return end;
}

View file

@ -128,7 +128,6 @@ export function readWire(payload: Uint8Array): unknown {
}
}
const tree = next();
if (offset !== payload.length) throw new Error("the MessagePack payload has bytes after its value.");
return tree;
// One value is read; the caller owns what follows it (decodeDocumentPayload refuses a payload with a tail).
return next();
}

View file

@ -119,6 +119,19 @@ test("a payload that ends early, is empty or carries trailing bytes is the reade
assert.doesNotThrow(() => checkPayloadDepth(bytes(0xc0)));
});
test("the walk returns where the first value ends, and undefined when the payload ends early", () => {
assert.equal(checkPayloadDepth(bytes(0xc0)), 1);
assert.equal(checkPayloadDepth(bytes(0xc0, 0xc1, 0xc1)), 1, "a top-level scalar ends at its own length; the tail is not judged");
assert.equal(checkPayloadDepth(bytes(0x92, 0x01, 0xa1, 0x61, 0xc0)), 5 - 1, "a container ends when its last child completes");
assert.equal(checkPayloadDepth(cat(bytes(0x90), arrays(L + 50))), 1, "the tail is never walked");
assert.equal(checkPayloadDepth(bytes()), undefined);
assert.equal(checkPayloadDepth(bytes(0x92, 0x01)), undefined);
assert.equal(checkPayloadDepth(bytes(0xd9)), undefined);
for (const cut of [[0xcc], [0xcb, 0x3f, 0xf8], [0xa5, 0x61, 0x62, 0x63], [0xc4, 0x02, 0x01], [0xd7, 0x01]]) {
assert.equal(checkPayloadDepth(bytes(...cut)), undefined, `a scalar cut short: ${cut.map((value) => value.toString(16)).join(" ")}`);
}
});
test("the walk is iterative: 100000 nested arrays are refused with the depth error, not a RangeError", () => {
refused(arrays(100_000), "100000 nested arrays");
refused(chain(bytes(0x81, 0x00), 100_000), "100000 nested maps");
@ -198,6 +211,13 @@ test("the inspector previews a payload at the limit and reports one past it, or
}
});
test("the inspector refuses bytes after a payload's first value with the shared clause and previews no tail", async () => {
const file = await storeWith(bytes(0xc0, 0xc1, 0xc1));
const record = inspectCultCacheBytes(file, await readFile(file)).records[0]!;
assert.equal(record.payloadDecodeError, "document payload has 2 bytes after its first MessagePack value.");
assert.deepEqual(record.payloadPreview, { bytes: 3, hexPrefix: "c0" });
});
// A header whose length or count needs all four bytes: the low sixteen bits of the length are small, the whole is not.
const be32 = (n: number) => bytes((n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff);

View file

@ -338,7 +338,7 @@ test("the wire reader keeps 64-bit integers whole, tells a float from an integer
assert.throws(() => readWire(fromHex("dd")), /truncated/u);
assert.throws(() => readWire(fromHex("ddffffffff")), /truncated/u, "a huge array header reserves nothing");
assert.throws(() => readWire(fromHex("a5616263")), /truncated/u);
assert.throws(() => readWire(fromHex("0102")), /bytes after its value/u);
assert.equal(readWire(fromHex("0102")), 1, "the reader reads one value; the document owner refuses what follows");
assert.throws(() => readWire(fromHex("81a161a162")), /map, extension or reserved/u);
assert.throws(() => readWire(fromHex("c1")), /map, extension or reserved/u);
assert.throws(() => readWire(fromHex("d4010a")), /map, extension or reserved/u);
@ -725,3 +725,114 @@ test("a cache load refuses a store whose record is past the limit, naming the ty
const refusing = CultCache.builder().withDocumentType(document).withGenericStore(memoryStore([envelopeOf(await read("depth-past-limit.msgpack"))])).build();
await assert.rejects(() => refusing.pullAllBackingStores(), (error: Error) => error instanceof CultCachePayloadDepthError && !error.message.includes(CANARY));
});
// ---- bytes-after vectors: a payload is exactly one MessagePack value (tests/vectors/union-parity/README.md) ----
// The C# reference writes the vectors: union-surface.msgpack followed by a tail the first value does not own. The one
// owner refuses by count, naming the type, from the walk's end; it never walks or echoes the tail.
async function bytesAfterEntries() {
const raw = decode(await read("bytes-after-outcomes.msgpack")) as unknown[][];
return raw.map(([file, outcome, unionName, key, detail]) => ({ file: file as string, outcome: outcome as string, unionName: unionName as string, key: key as number | null, detail: detail as string }));
}
// The C# vector writer's canary (BytesAfterVectorTests): it sits in the deep tail, so an echo of the tail would carry it.
const TAIL_CANARY = "CANARY-bytes-after-7d1e";
const valueLength = async () => (await read("union-surface.msgpack")).length;
const plainDocument = () => defineDocumentType({ type: "tests.plain_surface", schema: z.unknown() });
const codecDocument = () => defineDocumentType({ type: "tests.codec_surface", schema: surfaceSchema, codec: surfaceCodec });
async function assertBytesAfterRefused(file: string, tail: number, run: () => unknown | Promise<unknown>, type: string) {
const clause = `${type}`;
await assert.rejects(
async () => run(),
(error: Error) => {
assert.ok(!(error instanceof CultCachePayloadDepthError), `${file}: the tail was walked`);
assert.ok(!error.message.includes(CANARY) && !error.message.includes(TAIL_CANARY), `${file}: the refusal echoed the tail`);
assert.ok(!error.message.includes("the limit is"), `${file}: the depth refusal`);
assert.ok(error.message.includes(clause), `${file}: names the type: ${error.message}`);
assert.ok(error.message.includes(`${tail} bytes after its first MessagePack value`), `${file}: names the count: ${error.message}`);
return true;
},
file,
);
}
test("every bytes-after vector is refused by the one owner, by count, on the codec, plain and store paths", async () => {
const entries = await bytesAfterEntries();
assert.equal(entries.length, 4);
const length = await valueLength();
const surface = surfaceDocument();
const plain = plainDocument();
const codecOnly = codecDocument();
const dir = await mkdtemp(join(tmpdir(), "bytes-after-"));
for (const { file, outcome, unionName, detail } of entries) {
assert.equal(outcome, "refused");
assert.equal(unionName, "UnionSurface");
assert.equal(detail, "bytes-after");
const bytes = await read(file);
const tail = bytes.length - length;
assert.ok(tail > 0, file);
if (file === "bytes-after-deep-tail.msgpack") assert.ok(Buffer.from(bytes).includes(TAIL_CANARY), "the deep tail carries the canary the assertions look for");
const payload = documentPayloadFromBytes(bytes);
await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(surface, payload), surface.type);
await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(plain, payload), plain.type);
await assertBytesAfterRefused(file, tail, () => decodeDocumentPayload(codecOnly, payload), codecOnly.type);
// The store path: a single-file store holding the vector as a record payload refuses on load.
const path = join(dir, `${file}.cc`);
await new SingleFileMessagePackBackingStore(path).push(envelopeOf(bytes));
const cache = CultCache.builder().withDocumentType(surface).withGenericStore(new SingleFileMessagePackBackingStore(path)).build();
await assertBytesAfterRefused(file, tail, () => cache.pullAllBackingStores(), surface.type);
// The put path: an envelope arriving with the tail is refused and held nowhere.
const written: CultCacheEnvelope[] = [];
const held = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore(written)).build();
await assertBytesAfterRefused(file, tail, () => held.putEnvelope(surface, envelopeOf(bytes)), surface.type);
assert.equal(written.length, 0, `${file}: nothing reached the store`);
assert.equal(held.get(surface, "surface"), undefined, `${file}: nothing is held in memory`);
}
});
test("a payload with an unread slot and a tail is refused by count before any slot is marked or the record held", async () => {
const withSlot = await read("extra-slot-record.msgpack");
const surface = surfaceDocument();
const tailed = Uint8Array.from([...withSlot, 0xc0]);
const whole = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore([envelopeOf(withSlot)])).build();
await whole.pullAllBackingStores();
assert.ok(whole.unreadSlots(surface, "surface"), "without a tail the record is held with its unread slots");
const loading = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore([envelopeOf(tailed)])).build();
await assertBytesAfterRefused("extra-slot-record+tail", 1, () => loading.pullAllBackingStores(), surface.type);
assert.equal(loading.get(surface, "surface"), undefined);
assert.equal(loading.unreadSlots(surface, "surface"), undefined);
const written: CultCacheEnvelope[] = [];
const putting = CultCache.builder().withDocumentType(surface).withGenericStore(memoryStore(written)).build();
await assertBytesAfterRefused("extra-slot-record+tail", 1, () => putting.putEnvelope(surface, envelopeOf(tailed)), surface.type);
assert.equal(written.length, 0);
assert.equal(putting.get(surface, "surface"), undefined);
assert.equal(putting.unreadSlots(surface, "surface"), undefined);
});
test("a payload that ends early is not bytes after its value: the decoder refuses it, and a user formatter's bytes are not walked", async () => {
const whole = await read("union-surface.msgpack");
const surface = surfaceDocument();
for (const cut of [0, 1, 10, whole.length - 1]) {
assert.throws(
() => decodeDocumentPayload(surface, documentPayloadFromBytes(whole.slice(0, cut))),
(error: Error) => !error.message.includes("bytes after") && !(error instanceof CultCachePayloadDepthError),
`truncated at ${cut}`,
);
}
// A header cut short of its length is an early end too, whatever width it declares.
for (const early of ["dc00", "dd000000", "de00", "c4", "d9", "a5616263", "9291"]) {
assert.throws(
() => decodeDocumentPayload(surface, documentPayloadFromBytes(fromHex(early))),
(error: Error) => !error.message.includes("bytes after") && !(error instanceof CultCachePayloadDepthError),
`early end ${early}`,
);
}
const opaque = defineDocumentType({ type: "tests.opaque", schema: z.unknown(), formatter: { encode: (value: Uint8Array) => value, decode: (bytes: Uint8Array) => bytes } });
const tailed = await read("bytes-after-nil.msgpack");
assert.equal((decodeDocumentPayload(opaque, documentPayloadFromBytes(tailed)) as Uint8Array).length, tailed.length);
});

View file

@ -83,3 +83,16 @@ test("a provider command that is not a map, or lacks a required field, is refuse
(error: Error) => error.message === "CultMesh provider command must be a MessagePack map.",
);
});
test("a provider command payload with bytes after its first value is refused by count, without echoing the tail", () => {
const command = { commandId: "c1", commandKind: "k", providerId: "p", serviceInstanceId: "s", payload: { n: 1 } };
const whole = documentPayloadBytes(encodeDocumentPayload(providerCommandDocument, command));
const tailed = documentPayloadFromBytes(Uint8Array.from([...whole, ...Buffer.from("canary-tail-8c1f")]));
assert.throws(
() => decodeProviderCommandDocument({
schemaId: cultMeshProviderSessionSchemas.command, recordKey: "p/s/c1", storedAt: "2026-10-11T00:00:00Z",
payloadEncoding: "messagepack" as const, payload: tailed,
}),
(error: Error) => error.message.includes("16 bytes after its first MessagePack value") && !error.message.includes("canary-tail-8c1f"),
);
});

View file

@ -392,6 +392,13 @@ test("decodeManifest accepts and refuses exactly what the reference does, and re
}
});
test("a manifest with bytes after its first value is refused by count, whatever the tail is", () => {
const whole = encodeManifest(manifestOf());
for (const tail of [[0xc0], [0xc1, 0xc1], [0x90]]) {
assert.throws(() => decodeManifest(Uint8Array.from([...whole, ...tail])), new RegExp(`${tail.length} bytes after its first MessagePack value`));
}
});
test("a manifest written as a map names the manifest, not a field inside it", () => {
assert.throws(() => decodeManifest(encode({ 0: "a", 9: { k: "v" } })), /field 'manifest' has the wrong type/);
});

View file

@ -14,6 +14,8 @@ import {
SchemaConflictError,
SingleFileMessagePackBackingStore,
defineDocumentType,
documentPayloadBytes,
documentPayloadFromBytes,
schemaIdentityOf,
} from "@gamecult/cultcache-ts";
@ -3140,3 +3142,24 @@ test("CultNet registry names the canonical schema id of a compatible alias, and
assert.equal(registry.canonicalSchemaId("p3.nobody"), undefined);
assert.equal(registry.canonicalSchemaId("p3.shared"), undefined, "an id several bindings claim resolves to none");
});
test("a replicated record with bytes after its first MessagePack value is refused by count and held nowhere", async () => {
const definition = defineDocumentType({ type: "tails.note", schemaId: "tails.note.v1", schema: z.object({ body: z.string() }) });
const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition })]);
const origin = CultCache.builder().withDocumentType(definition).build();
await origin.put(definition, "k", { body: "x" });
const document = registry.createRawSnapshotResponse(origin, "tails").documents[0]!;
const whole = documentPayloadBytes(document.payload);
const target = CultCache.builder().withDocumentType(definition).build();
await assert.rejects(
registry.applyRawDocumentPutMessage(target, {
schemaVersion: "cultnet.document_put_raw.v0",
messageId: "tails",
document: { ...document, payload: documentPayloadFromBytes(Uint8Array.from([...whole, 0xc0, 0xc1])) },
}),
(error: Error) => error.message.includes('document type "tails.note" payload has 2 bytes after its first MessagePack value'),
);
assert.deepEqual(target.snapshot(), []);
await registry.applyRawDocumentPutMessage(target, { schemaVersion: "cultnet.document_put_raw.v0", messageId: "whole", document });
assert.deepEqual(target.getRequired(definition, "k"), { body: "x" });
});