The lone-surrogate row now carries the value in the chunk hash and id slots, so removing text() there fails. Closes follow_up content-id-sanitiser-pin's pin.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Identity: C2a's StoredSchemaId/StoredVersion owns; main's PersistedSchemaId, PersistedIdentity and both MarkWritten are deleted. Stamp rule: ReadStore judges the stamp before the body. Flush over damage refuses (merge-r1-precedence). Mesh single-file writes route through SingleFileMessagePackBackingStore. TS: store-file.ts commits, C2a's header handling kept. Rust: C2a's readable_store_format in front of the grown decoder. Build not yet verified at this commit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Manifest total and chunk sizes must be whole numbers below 2^53, and a chunk size must fit an int (2147483647), for hand-built values as well as decoded ones. Messages for the whole-number rule now name the bound. No C#-written vector changes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A manifest or chunk size that is a fraction, NaN or Infinity is refused in the one
validator. C# sizes are long, so the wire cannot carry one; the rule guards hand-built
manifests. The reader's private copy is deleted in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cultnet-ts/content re-exports isNullOrWhiteSpaceCSharp; the provider uses it
instead of key.trim(), which disagreed with C# on U+FEFF and U+0085.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
encodeManifest wrote a lone surrogate as CESU bytes for short strings and
U+FFFD past the msgpack TextEncoder threshold; C# writes U+FFFD always.
utf8-bound.ts now exports wellFormed (truncateUtf8Bytes uses it) and the
manifest encoder routes every string slot through it. Test crosses the
short/long boundary for every slot. Builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A nil manifest, chunks slot or chunk element reaches the array header read, which refuses it with the same field
name; the three pre-checks were equivalent mutants in the Stryker sweep.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stryker found 30 survivors in the reader. Those that were real gaps get C#-judged vectors (early ends, a fixint or
fixmap where an array or map header must be, every integer family at a value needing its width and sign, zero-filled
fixext, extras at the header-family edges) and a test that a refusal names its true cause (negative size, nil chunk,
reserved code, early end). The null checks ahead of isNullOrWhiteSpaceCSharp were redundant with it and are gone.
Vectors regenerate in the next commit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The encoder writes a number past uint32 as float64 under useBigInt64; the old tests leaned on the decoder accepting
that float. The reference refuses a float, so the fixtures now use bigint (uint64/int64) encodings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A purpose-built reader replaces the generic decoder: the integer rule sees the wire family (float-encoded
integers refused), nil in a reference slot reads as null and writes back as nil, missing trailing slots take the
reference's initialisers, extra slots and trailing bytes are skipped, invalid UTF-8 decodes to U+FFFD with the BOM
kept, and a map-form manifest is refused as 'manifest'. Validation still refuses nil chunks. Public type change:
string, tags and metadata fields are nullable. The acceptance vectors that judge this land in the next commits, so
content.test.ts is red until the C# writer has produced them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Source: stableToken carries no blank check of its own (artifactRecordKey owns the artifactId refusal, a blank version
never reaches it), the single-unit regex loses its anchors, and the integer reader is Number.isSafeInteger over a decoder
without useBigInt64 (a 64-bit value a JS number cannot hold rounds to 2^53 or beyond and is refused either way).
Tests: error messages name their field (table), map-header bytes at 0/15/16/65535/65536, WebCrypto absent, schema id and
default chunk size, chunk size 1, int32 chunk-size boundary, depth error subject, default parameter names.
Builds. Co-Authored-By below.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cultmesh-portability.yml gains 'Verify the content vectors with the C# reference' (CultMeshContentVectorTests), the first
CI run of the C#/Rust content parity and of the TypeScript manifest vectors. The README describes what cultnet-ts/content
owns and how the ts-written file is regenerated.
Builds. Co-Authored-By below.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One browser-safe module owns the manifest and chunk-ref shapes, their MessagePack form (positional ten-array, metadata as
ordered entries, duplicate keys and unsafe integers refused, payload depth through cultcache-ts's own checkPayloadDepth),
normalizeHash/parseSha256Hex, both record-key grammars (StableToken per UTF-16 unit), WebCrypto sha256Hex and packContent.
cultcache-ts/document re-exports checkPayloadDepth. scripts/write-cultmesh-content-vectors.mjs writes the TypeScript
manifest vectors for the C# reference to judge.
Builds. Co-Authored-By below.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sixteen conflicted files resolved by the spec's rule: main owns payload
ownership, bounds and the Clock seam; C2a owns persistence semantics and exact
binding. MintStoredAt is internal static (previous, now); the cache and the
store pass their cache's Clock. Package versions are main's. The cultmesh-ts
payload-parse acceptance and its test are deleted under retire-schema-alias;
peer snapshot reads take the schema's own id, else an id the definition lists
as compatible. The held firstRecordAtKey fallback is removed: main's handles
take definitions only, so nothing could reach it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DocumentPayload is an opaque object type over the same runtime Uint8Array, keyed by a string
property, and types CultCacheEnvelope.payload, CultNetRawDocumentRecord.payload and
CultMeshRealtimeFrame.payload. decodeDocumentPayload(definition, payload) and
encodeDocumentPayload(definition, value) are the only reader and writer; documentPayloadFromBytes
and documentPayloadBytes are the two named escapes at the edges where bytes enter and leave.
resolveDocumentFormatter leaves the package index. The cache, cultnet replication, the mesh read
paths, the provider command (now a CultCache document), and the browser's decodeCultNetPayload
(definition required, cultcache-ts/document entry) all go through the owner.
Compile-fail fixtures per payload-carrying package pin the type with expect-error lines.
check-typescript-tests-wired.mjs fails when a test file is run by no script; cultcache-ts now
runs dist-test/test/*.test.js, and the portability workflow runs the check and the cache suite.
Builds in every package (tsc, src and tests); the suites run once, at the end of the pass.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The two payload-decode-owner text-scan tests go, as do the string-schemaId overloads and
branches of the cultMeshDocumentFrom* functions and statics, decodeCultMeshPayload,
decodeCultNetRawDocumentPayload, toUint8Array, replication's second decodeDocumentValue, and the
undefined branch of decodeDocumentPayload. decodeDocumentPayload now requires the definition and
also runs its schema (the one reader). A publication binding names a definition, never a bare
schema id; the RUDP put handler refuses a schema with no registered definition instead of
plain-decoding it. Builds in every package; tests not yet rerun.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The RUDP put handler and the browser payload reader decoded a codec document with plain msgpack. cultcache-ts now
exports decodeDocumentPayload (definition or plain), the RUDP server reads a put through the registry's definition,
and decodeCultNetPayload takes the formatter. cultmesh-ts and cultmesh-browser no longer depend on @msgpack/msgpack:
frames and commands use cultnet-ts/wire, and a test per package scans src and the manifest. The envelope probe fixture
now runs the plain record first and last. Not yet built or tested on this commit; the verify job follows.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- cultnet-ts replication calls cultcache-ts's resolveDocumentFormatter (now exported) and loses its private
@msgpack/msgpack default, so a raw put and a snapshot of a codec document carry the codec's bytes.
- the hand-written numeric table is deleted; the tests read tests/vectors/numeric-leaves.
- an integer past 2^53 in a float32 slot rounds once (ties to even), not through a double.
- a union arm key is any Int32 (negative too, sorted numerically); a slot key is 0 to Int32 max.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cultnet-ts imports readSingleFileStore and cultmesh-ts imports schemaIdentityOf and SchemaConflictError, none of which cultcache-ts 0.14.0 exports. The new check builds each tagged release a declared range admits and requires every runtime name the dependent imports to be an export of it.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Mutation survivors of the authority reader (record count, schema id, type) and of the
reader's dangling-link error text each get a test that fails when the rule breaks.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
readAuthorityRecord decoded the store itself and accepted only cultcache.store.v1. It now calls
readSingleFileStore, so v3 reads, a zero-byte file is a StoreUnreadableError, and a refused
header is never echoed. Tests derive their bundles from the Rust-written fixtures.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
R1's rules win where both sides decided the same question: a zero-byte file is
not a store (refused as unreadable in every runtime), only nothing at the path is
empty, a dangling link is an I/O error, refusals never echo stored values and echo
a header only as cultcache.store.v<digits>, writes report Rejected, NotReplaced
and ReplacedNotDurable. C2a's rules win on what it added: the v3 element-id
header, variants, whole-file proof, typed unreadable errors in C#, TypeScript and
Python. Rust keeps R1's CultCacheStoreUnreadable{path, kind}; C2a's
StoreUnreadableError is deleted. The readability manifest's zero-byte row is
refuses x4.
Not yet built or tested: verification follows in the next commits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Send-failure semantics are Cut D's: a transient failure is counted and
never reported; a permanent one ends that peer's session.
Conflicts:
- cultmesh-ts index.ts: the two sendPackets. Cut D's
sendPacket(record, packet) is the one; the branch's reporting
sendPacket is deleted, and so is the options doc's "Every failed
datagram send reaches onError".
- cultmesh-py server.py: the rudp_send_failures doc is Cut D's. The
receive loop is Cut D's (handling guarded, permanent send errors
carried) with the branch's over_rudp=True. _send_rudp_schema_frame
sends in rudp_max_fragment_bytes fragments and returns Cut D's
permanent error from any fragment; a reply the queue cannot take no
longer vanishes into a count (below). _send_rudp_packet and the two
peer-ending helpers are Cut D's.
- cultmesh-py test imports: both create_rudp_schema_transport and
decode_rudp_packet.
Semantic resolution:
- cultmesh-rs: end_rejected_session and Cut D's end_refused_session and
end_unsendable_session collapse into one end_session(key, SessionEnd).
A refusal fragment that can never be sent is not dropped: the goodbye
names its error. The refusal stays unreliable, unordered, and
acknowledges nothing (its ack fields are built after the reset).
- cultmesh-rs: Cut D's "snapshot response could not be sent: {error}"
becomes CultMeshRudpRejectionReason::ResponseSendFailed(ErrorKind),
with a fixed REFUSAL_TEXTS entry. Its session ends as unsendable: the
peer gets the goodbye naming the error and no refusal.
- P3: a snapshot the reliable queue cannot take is answered.
cultmesh-py sends a small cultnet.error.v0 (response_unqueueable,
fixed text) unreliable and unordered, then the session ends as Cut D
ends any message the handling cannot serve. cultmesh-ts sends its
snapshot fallback error unreliable and unordered. cultmesh-rs already
answers through the refusal (ResponseQueueFailed); a test pins it.
- Tests follow: the branch's EMSGSIZE tests now expect Cut D's permanent
handling (the TS one is deleted, Cut D's own tests cover it), and the
Python queue test expects the answer instead of a count.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The harness assumed that the Rust interop peer builds under
packages/cultnet-rs/target. When CARGO_TARGET_DIR or a cargo config's
build.target-dir is set, cargo builds elsewhere, and every Rust lane
failed with a spawn ENOENT. The path now comes from `cargo metadata`'s
target_directory, read once on first use.
The TypeScript peer imports @gamecult/cultcache-ts, and the Python peers
import msgpack. When either was missing, the lanes died with import
errors in a child's stderr or with timeouts. The first use of each peer
now checks its prerequisite and fails with a message that names it and
the command that provides it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With only 1 KiB fragments, a pre-check measuring a small multiple of a
fragment (Soul's min(payload, frag*2)) still passed a 70,000-byte send.
The fixture now also runs with 40,000-byte fragments. Any such multiple
of that is over the datagram limit, so the mutant dies.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stryker survivors on 426c956a..fa266e58, each now a behaviour test:
- schemaIdentityOf's defaults (version "<name>.v1", compatible ids holding
only the own id) are asserted for a name-only definition.
- A lister bound twice is one lister: an unowned id it lists still
resolves to it rather than being refused as ambiguous.
- A snapshot filtered by an id a binding lists returns that binding's
records, on the typed and raw paths.
- The ambiguity refusal's record key and message tail are asserted, and a
raw snapshot record's payload encoding.
- A string-named store document reads the record under its id, and a
definition's own id wins over an id it lists wherever each record sits.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Soul F1, F2 and F3 on the registry. The F2 principle (operator,
2026-09-30): "CultCache never destroys or relabels a record it doesn't
understand; keeps it byte-for-byte, refuses writes that would overwrite it."
- F1: the registry derives every binding's identity with cultcache-ts's
schemaIdentityOf, so it stamps, refuses and resolves exactly as the cache
does. P1 (A with schema name "p1.shared", B with schema id "p1.shared")
is now refused by both.
- F2a: an id nobody owns resolves to the one binding that lists it as
compatible. Several listers and no owner is a SchemaConflictError naming
the id and the listers, whatever order they were registered in; an owner
beats every lister. This is refused at resolution, as the cache refuses
it at load, not at registration: refusing at registration would make the
outcome depend on whether the owner was registered before the listers.
- F2b: version inference is deleted. "p3.note.v7" no longer resolves to
the type named "p3.note" and gets relabelled "p3.note.v1".
- F2c: a schema version string, and a schema name, are not ids. Snapshot
filters, typed puts and deletes, and raw puts match only declared ids.
- F3: a raw record under an id no binding declares is refused. The
registry no longer tries each binding's parser and stores the record
under the first that accepts it (P5: an identity parser registered first
took everything).
- The dead resolvePayloadSchemaVersion helper is gone.
Tests: the three shape- and alias-routing tests are replaced by P1, P2,
P3/P4 and P5 as behaviour tests. The one-type-per-schema test registers two
bindings ahead of the holder (F6/M11).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The registry method existed on this branch only to size a receipt at put
admission in cultmesh-ts, which no longer does. It never reached main.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stryker left one survivor on 9adea366: emptying the refusal message. For a
same-type claimant the message did not name the schema id at all. It now
names the claimant's schema id in every case, and the refusal test checks
that it does. The message is built from document types and the schema id
only; registration carries no record, so no record value can reach it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The EMSGSIZE pre-check measures a send's largest datagram, which is one
fragment when maxFragmentBytes is set. Nothing pinned that: a check that
compared the whole payload (Soul's S-TM2) would refuse every large
fragmented send, and large sends are live. A 70,000-byte send with
1,024-byte fragments must not throw and must arrive whole. The test
kills the mutant whose size check ignores maxFragmentBytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
F7 (operator, 2026-09-30): "Schema aliasing sounds like a really bad idea,
I support retiring it." CultNetDocumentRegistry.register silently replaced
the binding for a schema id, so two types could claim one schema and the
last one registered won.
- register refuses a binding whose definition claims a type, schema id or
schema name another bound definition already holds, with the
SchemaConflictError CultCache uses for the same refusal: the new
definition's schema id, both schema names, an empty record key.
- Binding the same definition again still replaces its binding, and a
definition listing another's id as compatible is not a second owner.
- Test: identical-id, same-name and same-type claimants refused in both
registration orders, leaving the first binding in place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultNetDocumentRegistry.decodeRawDocumentPut returns the binding and
value applyRawDocumentPutMessage would store for a raw put, so a server
can build the receipt that would acknowledge a put before storing it.
It throws, as the put would, when no binding accepts the document.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node reports an oversized datagram only to the send's callback, so the
caller's send used to succeed and the session ended later. The transport now
checks the largest datagram of the send against the socket family's UDP
limit (65,507 bytes for IPv4, 65,527 for IPv6) before the session builds
anything: the send throws, nothing is queued, and no sequence is taken, as
the Rust and C# listeners withdraw it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A permanent failure reported after that generation ended (the client
connected again from the same endpoint) ended the generation that replaced
it and said goodbye to the new client. The callback now acts only while the
generation that sent the datagram is live, which is the one in `sessions`.
That makes two guards unreachable, so they are deleted: `peer.unsendable`
before the goodbye (the first permanent failure ends the generation, so a
second callback from it is already ignored) and the conditional delete (a
live generation is the entry at its key). A refusal's goodbye is sent
best-effort, as the transport and the document server send theirs, instead
of through the failure handling meant for datagrams a session owes.
Tests: late failure after a reconnect, for the transport's generation check
and for the operation server.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When that generation ends while the handler runs (a goodbye, a refusal, a
timeout, a new Connect from the same endpoint), the response is not sent
and a failure sends no second goodbye: a client that connected again owns
the endpoint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node reports a failed send only to the send's callback, never on the socket's
"error" event. Emitting it as "error" from the transport ended any process
with no listener (invokeCultNetOperation among them) once per resend tick.
The transport and the document server now count it in sendFailures, as the
operation server, Rust and Python already do. The tests send to
255.255.255.255 with no error listener.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Import lists in cultnet-ts index.ts and operation-service.ts and the
cultnet-py test block conflicted; both sides are kept.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node reports a failed UDP send two ways: it throws for an argument it
refuses (port 0, which any spoofer can claim as a source) and passes a
system failure to the callback later. sendRudpDatagram routes both to one
failure handler, and isPermanentSendError classifies with the permanent set
the other runtimes use (EMSGSIZE, EINVAL, EAFNOSUPPORT, and Node's own
ERR_SOCKET_BAD_PORT for port 0).
- The socket transport's receive and resend paths hand a failure to that
handler: a datagram that can never be sent ends the session (goodbye
names the error code, never its message); any other failure is emitted
on "error" as the socket always reported it. Caller-directed sends still
throw to the caller. A resend loop stops at the generation it ended.
- The operation server starts no session for a Connect whose Accept can
never be sent (counted as dropped), ends a stored peer whose datagram
can never be sent, and counts other failures in sendFailures.
- CultNetRudpSession.endUnsendableSession, as in the other runtimes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A server accepting a fresh Connect can send before its Accept is
acknowledged, so no ack of the new generation prunes the acknowledged
sizes; only `end_session` clearing them keeps the ended generation's bytes
from counting against the new window. Rust, TypeScript, Python and C# each
pin it: 4 MiB acknowledged above a lost packet, a fresh Connect, and the
server's first 1 MiB send of the new generation goes out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CultNetDocumentRegistry.createRawSnapshotResponseForPut(put, messageId)
returns the snapshot response that would serve the put's document alone:
the envelope applyRawDocumentPutMessage stores, as createRawSnapshotResponse
serves it. A server can judge a put by the size it would be served at.
applyRawDocumentPutMessage and the new method share one envelope builder,
and the served record comes from the snapshot path's own record builder,
so the size and the stored record cannot drift.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>