Publish browser subscription wire contracts

This commit is contained in:
Meta Vens 2026-08-17 23:26:51 +02:00
commit 3fe2e9be1b
14 changed files with 462 additions and 2 deletions

View file

@ -67,6 +67,8 @@ start with the maintained
[`CultMesh And Eve Getting Started`](src/GameCult.Mesh/docs/getting-started/README.md)
series. Its first executable package-consumer checkpoint is
[`samples/eve-two-runtime`](samples/eve-two-runtime/README.md).
The missing real-browser/network boundary is mapped explicitly in
[`browser-verse-transport.md`](src/GameCult.Mesh/docs/browser-verse-transport.md).
If you want only the smallest durable "open node -> put typed doc -> get typed doc"
path, use

View file

@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-change-raw.schema.json",
"title": "CultNet Raw Database Change Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "changeKind"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_change_raw.v0" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"changeKind": { "enum": ["added", "updated", "removed"] },
"document": { "$ref": "./cultnet.raw-document-record.schema.json" },
"recordKey": { "type": "string", "minLength": 1 },
"schemaId": { "type": "string", "minLength": 1 }
},
"allOf": [
{
"if": { "properties": { "changeKind": { "const": "removed" } } },
"then": { "required": ["recordKey", "schemaId"] },
"else": { "required": ["document"] }
}
]
}

View file

@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-subscribe.schema.json",
"title": "CultNet Database Subscribe Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "includeSnapshot"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_subscribe.v0" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"schemaIds": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"recordKeys": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"includeSnapshot": { "type": "boolean" },
"consumerRuntimeId": { "type": "string", "minLength": 1 },
"bodyIds": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"supportedBodyTransports": { "type": "array", "items": { "type": "string", "minLength": 1 } }
}
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json",
"title": "CultNet Database Unsubscribe Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_unsubscribe.v0" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 }
}
}

View file

@ -18,6 +18,9 @@ import transportProfileSchema from "../contracts/cultnet.transport-profile.schem
import snapshotRequestSchema from "../contracts/cultnet.snapshot-request.schema.json";
import snapshotResponseSchema from "../contracts/cultnet.snapshot-response.schema.json";
import snapshotResponseRawSchema from "../contracts/cultnet.snapshot-response-raw.schema.json";
import databaseSubscribeSchema from "../contracts/cultnet.database-subscribe.schema.json";
import databaseUnsubscribeSchema from "../contracts/cultnet.database-unsubscribe.schema.json";
import databaseChangeRawSchema from "../contracts/cultnet.database-change-raw.schema.json";
import schemaDescriptorSchema from "../contracts/cultnet.schema-descriptor.schema.json";
import schemaCatalogRequestSchema from "../contracts/cultnet.schema-catalog-request.schema.json";
import schemaCatalogResponseSchema from "../contracts/cultnet.schema-catalog-response.schema.json";
@ -44,6 +47,9 @@ export type CultNetSchemaVersion =
| "cultnet.snapshot_request.v0"
| "cultnet.snapshot_response.v0"
| "cultnet.snapshot_response_raw.v0"
| "cultnet.database_subscribe.v0"
| "cultnet.database_unsubscribe.v0"
| "cultnet.database_change_raw.v0"
| "cultnet.schema_catalog_request.v0"
| "cultnet.schema_catalog_response.v0"
| "cultnet.shard_catalog_request.v0"
@ -217,6 +223,34 @@ export interface CultNetSnapshotResponseRawMessage {
documents: CultNetRawDocumentRecord[];
}
export interface CultNetDatabaseSubscribeMessage {
schemaVersion: "cultnet.database_subscribe.v0";
messageId: string;
subscriptionId: string;
schemaIds?: string[];
recordKeys?: string[];
includeSnapshot: boolean;
consumerRuntimeId?: string;
bodyIds?: string[];
supportedBodyTransports?: string[];
}
export interface CultNetDatabaseUnsubscribeMessage {
schemaVersion: "cultnet.database_unsubscribe.v0";
messageId: string;
subscriptionId: string;
}
export interface CultNetDatabaseChangeRawMessage {
schemaVersion: "cultnet.database_change_raw.v0";
messageId: string;
subscriptionId: string;
changeKind: "added" | "updated" | "removed";
document?: CultNetRawDocumentRecord;
recordKey?: string;
schemaId?: string;
}
export interface CultNetSchemaDescriptor {
schemaId: string;
kind: CultNetSchemaKind;
@ -535,6 +569,9 @@ export type CultNetMessage =
| CultNetSnapshotRequestMessage
| CultNetSnapshotResponseMessage
| CultNetSnapshotResponseRawMessage
| CultNetDatabaseSubscribeMessage
| CultNetDatabaseUnsubscribeMessage
| CultNetDatabaseChangeRawMessage
| CultNetSchemaCatalogRequestMessage
| CultNetSchemaCatalogResponseMessage
| CultNetShardCatalogRequestMessage
@ -562,6 +599,9 @@ const CULTNET_MESSAGE_SCHEMAS = [
snapshotRequestSchema,
snapshotResponseSchema,
snapshotResponseRawSchema,
databaseSubscribeSchema,
databaseUnsubscribeSchema,
databaseChangeRawSchema,
schemaCatalogRequestSchema,
schemaCatalogResponseSchema,
shardCatalogRequestSchema,
@ -620,6 +660,11 @@ export function parseCultNetMessage(input: unknown, wireContract: CultNetWireCon
return input as CultNetSnapshotResponseRawMessage;
}
if (schemaVersion === "cultnet.database_change_raw.v0") {
validateDatabaseChangeRawMessage(input);
return input as CultNetDatabaseChangeRawMessage;
}
if (!validator(input)) {
const keys = Object.keys(input as Record<string, unknown>).join(",");
throw new Error(`${renderValidationErrors(schemaVersion, validator)}; keys=${keys}`);
@ -951,9 +996,39 @@ function validateSnapshotResponseRawMessage(
});
}
function validateDatabaseChangeRawMessage(
input: unknown,
): asserts input is CultNetDatabaseChangeRawMessage {
const candidate = validateBinaryMessageRoot(input, "cultnet.database_change_raw.v0");
requireNonEmptyString(candidate.subscriptionId, "/subscriptionId");
if (candidate.changeKind !== "added" &&
candidate.changeKind !== "updated" &&
candidate.changeKind !== "removed") {
throw new Error(
"Validation failed for cultnet.database_change_raw.v0: /changeKind: must be added, updated, or removed",
);
}
if (candidate.changeKind === "removed") {
requireNonEmptyString(candidate.recordKey, "/recordKey");
requireNonEmptyString(candidate.schemaId, "/schemaId");
return;
}
if (!("document" in candidate)) {
throw new Error(
"Validation failed for cultnet.database_change_raw.v0: /document: is required for added or updated changes",
);
}
validateRawDocumentRecord(candidate.document, "/document");
}
function validateBinaryMessageRoot(
input: unknown,
schemaVersion: "cultnet.document_put_raw.v0" | "cultnet.snapshot_response_raw.v0",
schemaVersion:
| "cultnet.document_put_raw.v0"
| "cultnet.snapshot_response_raw.v0"
| "cultnet.database_change_raw.v0",
): Record<string, unknown> {
if (!input || typeof input !== "object") {
throw new Error(`Validation failed for ${schemaVersion}: /: must be an object`);
@ -1043,6 +1118,15 @@ function normalizeCultNetOptionalNulls(
acceptLegacyPropertyName(candidate, "ShardEpoch", "shardEpoch");
stripNullProperties(candidate, ["schemaIds", "recordKeys", "shardId", "shardEpoch"]);
return;
case "cultnet.database_subscribe.v0":
stripNullProperties(candidate, [
"schemaIds",
"recordKeys",
"consumerRuntimeId",
"bodyIds",
"supportedBodyTransports",
]);
return;
case "cultnet.schema_catalog_request.v0":
stripNullProperties(candidate, ["schemaIds", "kinds"]);
return;
@ -1092,6 +1176,9 @@ export const cultNetSchemas = {
snapshotRequestSchema,
snapshotResponseSchema,
snapshotResponseRawSchema,
databaseSubscribeSchema,
databaseUnsubscribeSchema,
databaseChangeRawSchema,
schemaDescriptorSchema,
schemaCatalogRequestSchema,
schemaCatalogResponseSchema,

View file

@ -8,6 +8,9 @@ export {
type CultNetDocumentPutMessage,
type CultNetDocumentPutRawMessage,
type CultNetDocumentRecord,
type CultNetDatabaseSubscribeMessage,
type CultNetDatabaseUnsubscribeMessage,
type CultNetDatabaseChangeRawMessage,
type CultNetRawDocumentRecord,
type CultNetRawPayloadEncoding,
type CultNetErrorMessage,

View file

@ -1261,6 +1261,64 @@ test("operation envelopes preserve typed service routing and payload correlation
assert.equal(response.messageId, request.messageId);
});
test("database subscription contracts match the C# live document lane", () => {
const subscribe = parseCultNetMessage({
schemaVersion: "cultnet.database_subscribe.v0",
messageId: "subscribe-counter",
subscriptionId: "counter:browser-1",
schemaIds: ["sample.counter_state.v1"],
recordKeys: ["counter:main"],
includeSnapshot: true,
consumerRuntimeId: "browser-1",
});
assert.equal(subscribe.schemaVersion, "cultnet.database_subscribe.v0");
assert.equal(subscribe.subscriptionId, "counter:browser-1");
const added = parseCultNetMessage({
schemaVersion: "cultnet.database_change_raw.v0",
messageId: "change-counter-1",
subscriptionId: "counter:browser-1",
changeKind: "added",
document: {
schemaId: "sample.counter_state.v1",
recordKey: "counter:main",
storedAt: "2026-08-17T00:00:00Z",
payloadEncoding: "messagepack",
payload: new Uint8Array([0x81, 0xa5, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x01]),
},
});
assert.equal(added.schemaVersion, "cultnet.database_change_raw.v0");
assert.equal(added.document?.recordKey, "counter:main");
const removed = parseCultNetMessage({
schemaVersion: "cultnet.database_change_raw.v0",
messageId: "change-counter-2",
subscriptionId: "counter:browser-1",
changeKind: "removed",
recordKey: "counter:main",
schemaId: "sample.counter_state.v1",
});
assert.equal(removed.schemaVersion, "cultnet.database_change_raw.v0");
assert.equal(removed.recordKey, "counter:main");
const unsubscribe = parseCultNetMessage({
schemaVersion: "cultnet.database_unsubscribe.v0",
messageId: "unsubscribe-counter",
subscriptionId: "counter:browser-1",
});
assert.equal(unsubscribe.schemaVersion, "cultnet.database_unsubscribe.v0");
assert.throws(
() => parseCultNetMessage({
schemaVersion: "cultnet.database_change_raw.v0",
messageId: "invalid-change",
subscriptionId: "counter:browser-1",
changeKind: "updated",
}),
/document: is required/,
);
});
test("RUDP operation service correlates typed payload envelopes", async () => {
const server = await startCultNetOperationServer({
runtimeId: "sai-sidecar",

View file

@ -0,0 +1,156 @@
# Browser Verse Transport Boundary
Date: 2026-08-17
## Status
This is the implementation map for the missing real-browser checkpoint. The
current `samples/eve-two-runtime` proof uses jsdom and one local TypeScript
CultMesh node. It is a clean package-consumer and contract-validation proof,
not browser networking.
The current substrate is incomplete:
- `cultmesh-ts` is a Node package and imports `node:dgram` at its root;
- `cultnet-ts` mixes browser-safe schema contracts with Node transport,
filesystem, crypto, stream, and UDP modules;
- C# and TypeScript now share `cultnet.database_subscribe.v0`,
`cultnet.database_unsubscribe.v0`, and `cultnet.database_change_raw.v0`;
the browser-safe transport package that consumes them does not exist yet;
- Kotlin has a channel-aware WebSocket transport adapter; C# and TypeScript do
not expose the equivalent browser/server pair;
- Hermodr can lower Odin state into a browser, but it is an infrastructure
runtime, not the missing clean-consumer game API.
## Authority Map
Owner:
- CultNet owns binary WebSocket framing and schema-v0 message validation.
- CultMesh owns stable Verse/provider identity, document leases,
subscriptions, route replacement, and command/receipt correlation.
- Odin owns discovery of the current `wss://` candidate for a stable Verse and
provider identity.
- The provider daemon owns application state, operations, receipts, and the Eve
surface.
- Eve owns surface and command contracts; the browser lowerer owns DOM only.
Inputs:
- an Odin-discovered WebSocket transport candidate;
- stable Verse and provider identities;
- authenticated session evidence;
- requested schema ids and record keys;
- typed operation invocations and idempotency keys;
- canonical CultNet schema-v0 MessagePack messages.
Outputs:
- leased typed document snapshots and live changes;
- typed operation receipts;
- explicit connection/session diagnostics;
- deterministic unsubscribe and disposal;
- route replacement without changing provider or document identity.
Derived state:
- browser caches, DOM trees, loading/error presentation, and optimistic visual
affordances are projections only;
- a socket URL is a disposable discovery candidate, never provider identity;
- reconnect status is observable session state, not application truth.
Forbidden writers:
- browser lowerers cannot mutate provider documents directly;
- Hermodr, service workers, renderer caches, and app-specific HTTP endpoints
cannot become alternate gameplay authorities;
- no JSON polling API may mirror the database as a second state system;
- `node:dgram`, Node streams, filesystem stores, and Node crypto cannot enter a
browser bundle;
- a late socket, stale subscription generation, or reconnect replay cannot
overwrite a newer session;
- application code cannot own reconnect sleeps, endpoint ranking, or
resubscription loops.
Shared paths:
- initial load, live update, reconnect, tab background/resume, command retry,
and provider route rotation all use one CultMesh document-lease/session path;
- browser, Unity, headless C#, and future native clients consume the same
provider-owned documents and receipts;
- every command crosses the same typed operation boundary regardless of which
Eve lowerer emitted it.
Deletion line:
- stop calling jsdom a browser runtime;
- split browser-safe CultNet contracts from the Node transport package;
- add the missing subscription messages to the shared generated contract
catalog instead of retyping them in a browser adapter;
- do not retain a sample-only WebSocket protocol after the canonical adapter
exists.
## Required Runtime Shape
The browser-facing package must be importable by a standards-mode browser or a
normal bundler without Node polyfills. It exposes a narrow API shaped like:
```ts
const mesh = await CultMeshBrowser.connect({
rendezvous: odin,
verseId: "sample.counter",
providerId: "sample.counter-provider",
});
using surface = await mesh.leaseDocument(eveSurface, "eve:surface:counter");
surface.watch(render);
const receipt = await mesh.invoke(incrementCounter, {}, {
idempotencyKey: crypto.randomUUID(),
});
```
The names are illustrative. The ownership is not: application code supplies
identity and intent; CultMesh owns route/session continuity; the provider owns
the result.
## Wire Contract
WebSocket carries binary MessagePack CultNet schema-v0 messages. The first
implementation must support:
- `cultnet.hello.v0`;
- authentication/session verification;
- `cultnet.snapshot_request.v0` and
`cultnet.snapshot_response_raw.v0`;
- `cultnet.database_subscribe.v0`,
`cultnet.database_unsubscribe.v0`, and
`cultnet.database_change_raw.v0`;
- `cultnet.operation_request.v0` and
`cultnet.operation_response.v0`;
- `cultnet.error.v0`.
The WebSocket frame is a transport body, not a second message schema. Payload
size, queue depth, subscription count, and command rate are bounded. Schema,
identity, authority, and idempotency validation happen before provider code.
## Acceptance Gate
From an empty temporary consumer using packed/released artifacts only:
1. start a provider daemon with one durable counter document and one validated
Eve surface;
2. advertise it by stable identity through a local Odin fixture or real Odin;
3. connect one real Chromium page and one C# headless client;
4. lease the same surface and counter document in both runtimes;
5. click the browser button and require one provider-authored canonical receipt;
6. observe the same state version and receipt id in both clients;
7. disconnect or rotate the physical WebSocket endpoint, then require both
leases to resume without application reconnect code;
8. restart the provider and prove state plus receipt persistence;
9. reject malformed schemas, direct document writes, duplicate command
effects, stale subscription generations, and unsupported authority;
10. inspect the browser bundle and fail if it contains Node builtin imports.
The gate runs on Windows and Linux. A screenshot is useful presentation
evidence, but the receipt/state chronology is the authority proof.

View file

@ -65,5 +65,7 @@ This checkpoint proves clean package consumption, canonical Eve contract
validation, and two independent lowering/observation consumers inside Node. It
does **not** claim a real browser process, an Odin-discovered network hop, or
cross-language command execution. Those are the next public Verse conformance
gates; do not cite this sample as evidence that they exist.
gates; do not cite this sample as evidence that they exist. Their owner map and
executable acceptance contract are recorded in
[`browser-verse-transport.md`](../browser-verse-transport.md).

View file

@ -0,0 +1,25 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-change-raw.schema.json",
"title": "CultNet Raw Database Change Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "changeKind"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_change_raw.v0" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"changeKind": { "enum": ["added", "updated", "removed"] },
"document": { "$ref": "./cultnet.raw-document-record.schema.json" },
"recordKey": { "type": "string", "minLength": 1 },
"schemaId": { "type": "string", "minLength": 1 }
},
"allOf": [
{
"if": { "properties": { "changeKind": { "const": "removed" } } },
"then": { "required": ["recordKey", "schemaId"] },
"else": { "required": ["document"] }
}
]
}

View file

@ -0,0 +1,20 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-subscribe.schema.json",
"title": "CultNet Database Subscribe Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId", "includeSnapshot"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_subscribe.v0" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 },
"schemaIds": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"recordKeys": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"includeSnapshot": { "type": "boolean" },
"consumerRuntimeId": { "type": "string", "minLength": 1 },
"bodyIds": { "type": "array", "items": { "type": "string", "minLength": 1 } },
"supportedBodyTransports": { "type": "array", "items": { "type": "string", "minLength": 1 } }
}
}

View file

@ -0,0 +1,14 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultnet.database-unsubscribe.schema.json",
"title": "CultNet Database Unsubscribe Message",
"type": "object",
"required": ["schemaVersion", "messageId", "subscriptionId"],
"additionalProperties": false,
"properties": {
"schemaVersion": { "const": "cultnet.database_unsubscribe.v0" },
"messageId": { "type": "string", "minLength": 1 },
"subscriptionId": { "type": "string", "minLength": 1 }
}
}

View file

@ -370,6 +370,27 @@ namespace GameCult.Networking
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-subscribe.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DatabaseSubscribe,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-unsubscribe.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DatabaseUnsubscribe,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.database-change-raw.schema.json",
Kind = "wire_message",
SchemaVersion = CultNetSchemaVersions.DatabaseChangeRaw,
WireContracts = new[] { CultNetWireContracts.SchemaV0 }
},
new SchemaResourceSpec
{
FileName = "cultnet.schema-descriptor.schema.json",
Kind = "shared_contract",

View file

@ -5282,6 +5282,12 @@ namespace GameCult.Networking.Tests
schema => schema.SchemaVersion == CultNetSchemaVersions.DocumentPutRaw);
var rawSnapshot = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.SnapshotResponseRaw);
var subscribe = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseSubscribe);
var unsubscribe = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseUnsubscribe);
var change = Array.Find(response.Schemas,
schema => schema.SchemaVersion == CultNetSchemaVersions.DatabaseChangeRaw);
var ghostlight = Array.Find(response.Schemas,
schema => schema.SchemaVersion == "ghostlight.agent_state.v0");
var transportProfile = Array.Find(response.Schemas,
@ -5295,6 +5301,13 @@ namespace GameCult.Networking.Tests
Assert.That(rawSnapshot, Is.Not.Null);
Assert.That(rawSnapshot!.SchemaJson, Does.Contain("cultnet.snapshot_response_raw.v0"));
Assert.That(subscribe, Is.Not.Null);
Assert.That(subscribe!.SchemaJson, Does.Contain("cultnet.database_subscribe.v0"));
Assert.That(unsubscribe, Is.Not.Null);
Assert.That(unsubscribe!.SchemaJson, Does.Contain("cultnet.database_unsubscribe.v0"));
Assert.That(change, Is.Not.Null);
Assert.That(change!.SchemaJson, Does.Contain("cultnet.database_change_raw.v0"));
Assert.That(ghostlight, Is.Not.Null);
Assert.That(ghostlight!.DocumentType, Is.EqualTo("ghostlight.agent-state"));
Assert.That(ghostlight.Kind, Is.EqualTo("document_payload"));