Stamp hardening: refuse a peer raw put with no stamp in C#, TypeScript and Rust; Python replication #175

Open
libby wants to merge 12 commits from golden-write/stamp-hardening into main
32 changed files with 1037 additions and 41 deletions

View file

@ -115,7 +115,7 @@
"validPutPayloadHex": "81A16101",
"whiteSpaceCodePoints": ["0009","000A","000B","000C","000D","0020","0085","00A0","1680","2000","2001","2002","2003","2004","2005","2006","2007","2008","2009","200A","2028","2029","202F","205F","3000"],
"notWhiteSpaceCodePoints": ["200B","200C","200D","2060","FEFF","180E","001C","001D","001E","001F"],
"codePointCases": "For every code point in whiteSpaceCodePoints, each of schemaId, recordKey, storedAt, sourceRuntimeId, sourceAgentId, sourceRole and one tags entry, set alone to a string of just that code point (and to that code point repeated three times), is refused; for every code point in notWhiteSpaceCodePoints the same string is not blank and the put is admitted.",
"codePointCases": "For every code point in whiteSpaceCodePoints, each of schemaId, recordKey, storedAt, sourceRuntimeId, sourceAgentId, sourceRole and one tags entry, set alone to a string of just that code point (and to that code point repeated three times), is refused; for every code point in notWhiteSpaceCodePoints the same string in each of those fields except storedAt is not blank and the put is admitted (a storedAt must be a stamp: a non-blank storedAt that is no stamp is refused message_invalid, field document, after the write authority, and is covered by tests/vectors/stored-at-stamp).",
"cases": [
{ "name": "valid_document_is_admitted", "set": {}, "outcome": "admitted" },
{ "name": "empty_schema_id", "set": { "schemaId": "" }, "outcome": "refused" },
@ -140,7 +140,7 @@
{ "name": "zero_width_space_schema_id_is_a_name", "set": { "schemaId": "\u200b" }, "outcome": "admitted" },
{ "name": "zero_width_space_record_key_is_a_name", "set": { "recordKey": "\u200b" }, "outcome": "admitted" },
{ "name": "zero_width_space_tag_is_a_name", "set": { "tags": ["\u200b"] }, "outcome": "admitted" },
{ "name": "bom_stored_at_is_a_name", "set": { "storedAt": "\ufeff" }, "outcome": "admitted" },
{ "name": "bom_record_key_is_a_name", "set": { "recordKey": "\ufeff" }, "outcome": "admitted" },
{ "name": "mongolian_vowel_separator_source_role_is_a_name", "set": { "sourceRole": "\u180e" }, "outcome": "admitted" },
{ "name": "padded_name_is_a_name", "set": { "schemaId": "\u00a0a\u2003", "tags": [" a"] }, "outcome": "admitted" },
{ "name": "file_separator_schema_id_is_a_name", "set": { "schemaId": "\u001c" }, "outcome": "admitted" },

View file

@ -40,7 +40,7 @@ A connection is receipt-capable when its `cultnet.hello.v0` (sent before its fir
- **What is remembered.** Per entry a server keeps only fixed-size digests (SHA-256) of the `messageId` and of the content hash, plus the answer (`admitted`, or the refusal's code and details). Memory per entry is bounded independent of id length and payload size. Lookup compares digests.
- **Remembered-write limits.** The per-session table holds at most 1024 entries. A server MAY also enforce a server-wide memory budget across all sessions (reference: 32 MiB of remembered entries). A NEW write (a key not already in the table) that would exceed either bound is refused `limit_reached` for that write alone, `messageId` the write's id, `details` all null; the write is not applied and the session continues. A resend of a remembered key is still answered from the table.
- **Limit refusals are remembered.** A `limit_reached` refusal is remembered like any other: a resend of the same id and content hash replays `limit_reached`. No slot is freed mid-session, so an id refused at a bound never becomes admitted later in that session (a server at the cap keeps the refused digest pair in a bounded refusal set of at most 1024 per session, outside the entry budget; past it, a further new write is refused `limit_reached` without being remembered, and the session refuses every id it has not seen for the rest of its life, even if a server-wide budget has since freed, so a refusal that was not remembered is never followed by an admission of the same id). The client mints a new id on a new session.
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A put or delete that is present but names nothing is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"`, the fixed unreadable text and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case.
- **Required fields.** A put or delete whose document or `schemaId` is null or missing is refused `message_invalid` with `details.field` `"document"` or `"schemaId"` (the first missing in that order) and fixed text; the write is not applied. A put or delete that is present but names nothing is not "missing": the write is refused `message_invalid` exactly as a frame that does not read, with `details.field` `"message"`, the fixed unreadable text and the write's id (vector `error_v1_empty_schema_id_is_unreadable_and_names_the_write`), and is never applied; a server must not hand it to its store. Names nothing means, on a put or delete, a `schemaId` or `recordKey` that is blank; on a put's document, also a `storedAt` that is blank, a `payload` of zero bytes (a MessagePack value is at least one byte, so an empty payload is no document), a `sourceRuntimeId`, `sourceAgentId` or `sourceRole` that is present and blank, and a `tags` entry that is blank. **Blank** means empty or made only of Unicode White_Space code points (the PropList property) and nothing else, in every runtime, whatever its platform's own whitespace test says. The set is exactly U+0009 to U+000D, U+0020, U+0085, U+00A0, U+1680, U+2000 to U+200A, U+2028, U+2029, U+202F, U+205F and U+3000. U+200B, U+200C, U+200D, U+2060, U+FEFF, U+180E and U+001C to U+001F are not White_Space (some platforms' whitespace tests accept U+001C to U+001F or U+180E, for example Java's `Character.isWhitespace` and older .NET, so a runtime must check the list, not its platform test): a string of them names something. The vectors list both sets (`whiteSpaceCodePoints`, `notWhiteSpaceCodePoints`) and a runtime checks every code point in every covered field. A put's `storedAt` is the exception to "present and not blank is a name": it must be a stamp, so a non-blank `storedAt` that is no stamp (`tests/vectors/stored-at-stamp`) is refused `message_invalid` with `details.field` `"document"`, after the write authority has decided (an ungranted peer is told `write_not_authorized` first), and nothing is stored or minted; only a blank `storedAt` is refused as unreadable with field `"message"`, before the authority. The non-White_Space code point rows therefore name something in every covered field except `storedAt`. Absent or nil source ids and tags, and an empty `tags` list, are valid. The rule holds on every connection, receipt-capable or not (a legacy connection is refused with the `error.v0` of the same code, details and text); the vectors' `blankFields` section lists each case.
- **Fixed texts.** The `error` string of these refusals is fixed, so vectors can compare it: `message_invalid: messageId exceeds 256 bytes`, `message_invalid: document is required`, `message_invalid: schemaId is required`, `message_invalid: the message could not be read` (a frame that does not decode, or a write that names nothing; the text is the same for both and never carries a decoder's own wording), `message_invalid: receipt sessions require the reliable channel`, `limit_reached: the session's remembered-write limit is full`.
- **Reliable channel only.** A write is accepted on a receipt session only on a channel that is reliable and not sequenced (reliable ordered and reliable unordered both qualify; a sequenced channel drops superseded packets, so a write on it can vanish without trace). A write arriving on any other channel is refused `message_invalid` with `details.field: "channel"` (the transport profile's word for it) and fixed text. Receipts and refusals travel the reliable channel; a write that could be lost without trace cannot be answered once. The server refuses a sequenced write whenever it can read the write's id: from the delivered frame, and from a superseded packet that carries its whole frame. A write sent as several fragments whose frame is superseded is dropped whole and may go unanswered; the client's unconfirmed outcome covers it. A repeat of a packet the server already took, or a packet too far ahead to be taken, draws no further answer (rule 1).
- Past the session (or a server's longer retention, if it keeps one) a resend is a new write.

View file

@ -13,6 +13,7 @@ only in git history.
### Changed
- `is_stored_at_stamp(&str)` (new, public) is the one shape a peer's `storedAt` must parse as, pinned by `tests/vectors/stored-at-stamp` (RFC 3339 `YYYY-MM-DDThh:mm:ss`, a 1 to 9 digit fraction, `Z` or `+hh:mm`/`-hh:mm`; uppercase `T`/`Z`, a real date, seconds 00-59, years 0001-9999), hand-checked because `chrono`'s RFC 3339 reader takes other shapes. `put_raw_envelope` and `put_prepared_batch` refuse a `stored_at` that is not a stamp (before: only a blank one) and mint nothing (variants:ruling:raw-put-without-stamp-refused).
- The single-file store's `push`, `push_all` and `commit_if` stamp a replacing write strictly later than the record it replaces at the same key (a staged stamp stands only when it parses and is strictly later; otherwise the stamp is minted as the replaced stamp plus one microsecond, or now if later), with no identical-content exception, as C# does. A stamp with no successor (the replaced stamp is at year 9999's last microsecond) refuses the write and writes nothing, as C# `MintStoredAt` does. `commit_if` replaces only the record of the entry's own type under the key, so a retype is refused as `push` refuses it. The cache holds the stamp the store wrote. The redb stores and the store-level exact-envelope writes (`compare_and_swap_entry` and its family) still write the caller's envelope unchanged.
- The single-file store re-checks for a link once the store lock is held (every write and the try-exchange), so a link put at the path while a writer waited on the lock is refused with `StoreLinkedError` and the lock released, as C# does, instead of being replaced. Pinned by a test that puts the link while a second holder owns the lock.
- `CultCache::is_writable` does not look at store paths: a link at a store path is not predicted, and the write refuses it with `StoreLinkedError`. Pinned by a test through a cache attached to a linked store.

View file

@ -3761,12 +3761,7 @@ impl CultCache {
entry.r#type
));
}
if entry.stored_at.trim().is_empty() {
return Err(anyhow!(
"CultCache envelope stored_at for type {:?} must be non-empty",
entry.r#type
));
}
require_stored_at_stamp(entry)?;
require_record_payload(entry)?;
self.refuse_unread_slots(&entry.r#type, &entry.key)?;
self.refuse_claimed_id(entry)?;
@ -3843,12 +3838,7 @@ impl CultCache {
entry.r#type
));
}
if entry.stored_at.trim().is_empty() {
return Err(anyhow!(
"CultCache envelope stored_at for type {:?} must be non-empty",
entry.r#type
));
}
require_stored_at_stamp(&entry)?;
require_record_payload(&entry)?;
self.refuse_unread_slots(&entry.r#type, &entry.key)?;
self.refuse_claimed_id(&entry)?;
@ -4328,6 +4318,73 @@ fn restamped_over(entry: &CultCacheEnvelope, held: &[CultCacheEnvelope]) -> Resu
Ok(entry)
}
/// Whether `text` is a `stored_at` stamp: the one shape pinned in `tests/vectors/stored-at-stamp/`. An RFC 3339 date-time,
/// `YYYY-MM-DDThh:mm:ss`, an optional fraction of 1 to 9 digits, then `Z` or `+hh:mm` / `-hh:mm` (hours 00-23, minutes 00-59);
/// uppercase `T` and `Z`, ASCII digits, no surrounding whitespace, a real calendar date, seconds 00-59, a local year of 0001-9999, and an instant (the
/// offset applied) within years 0001-9999. Checked by hand, not by `chrono`'s RFC 3339 reader, which takes other shapes
/// (lowercase `t`, a space, a leap second). A peer's `storedAt` is stored as it arrives, so one that is not a stamp is refused.
pub fn is_stored_at_stamp(text: &str) -> bool {
let b = text.as_bytes();
let digits = |from: usize, count: usize| -> Option<u32> {
let part = b.get(from..from + count)?;
part.iter().all(u8::is_ascii_digit).then(|| part.iter().fold(0, |n, d| n * 10 + u32::from(d - b'0')))
};
let at = |index: usize, byte: u8| b.get(index) == Some(&byte);
let (Some(year), Some(month), Some(day), Some(hour), Some(minute), Some(second)) =
(digits(0, 4), digits(5, 2), digits(8, 2), digits(11, 2), digits(14, 2), digits(17, 2))
else {
return false;
};
if !(at(4, b'-') && at(7, b'-') && at(10, b'T') && at(13, b':') && at(16, b':')) {
return false;
}
let mut next = 19;
if at(next, b'.') {
let fraction = b[next + 1..].iter().take_while(|byte| byte.is_ascii_digit()).count();
if !(1..=9).contains(&fraction) {
return false;
}
next += 1 + fraction;
}
let offset_seconds = if b.len() == next + 1 && at(next, b'Z') {
0
} else if b.len() == next + 6 && (at(next, b'+') || at(next, b'-')) && at(next + 3, b':') {
let (Some(hours), Some(minutes)) = (digits(next + 1, 2), digits(next + 4, 2)) else {
return false;
};
if hours > 23 || minutes > 59 {
return false;
}
let magnitude = i64::from(hours * 3600 + minutes * 60);
if at(next, b'-') { -magnitude } else { magnitude }
} else {
return false;
};
if second > 59 || year < 1 {
return false;
}
let Some(local) = chrono::NaiveDate::from_ymd_opt(year as i32, month, day)
.and_then(|date| date.and_hms_opt(hour, minute, second))
else {
return false;
};
use chrono::Datelike;
local
.checked_sub_signed(chrono::TimeDelta::seconds(offset_seconds))
.is_some_and(|utc| (1..=9999).contains(&utc.year()))
}
/// A raw envelope is stored under the stamp it carries, so one that is no stamp is refused and nothing is minted for it.
fn require_stored_at_stamp(entry: &CultCacheEnvelope) -> Result<()> {
if is_stored_at_stamp(&entry.stored_at) {
return Ok(());
}
Err(anyhow!(
"CultCache envelope stored_at for type {:?} must be an RFC 3339 stamp",
entry.r#type
))
}
fn now_utc_second() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
@ -6895,7 +6952,7 @@ mod tests {
let (mut empty_stored_at, _) = cache.prepare_entry("n", &Note { title: "t".to_string(), body: "b".to_string() })?;
empty_stored_at.stored_at = " ".to_string();
let error = cache.put_prepared_batch(vec![empty_stored_at]).unwrap_err();
assert!(error.to_string().contains("stored_at for type \"note\" must be non-empty"), "{error}");
assert!(error.to_string().contains("stored_at for type \"note\" must be an RFC 3339 stamp"), "{error}");
assert_eq!(cache.snapshot(), before);
assert_eq!(fs::read(&store_path)?, store_bytes);

View file

@ -0,0 +1,58 @@
//! `tests/vectors/stored-at-stamp`: the one shape a peer's `storedAt` must parse as. Every row is walked against
//! `is_stored_at_stamp`, and each row that is no stamp against the cache's two raw doors, `put_raw_envelope` and
//! `put_prepared_batch`: refused, nothing stored or minted, the store still writable after
//! (`variants:ruling:raw-put-without-stamp-refused`).
use std::path::PathBuf;
use cultcache_rs::{CacheBackingStore, CultCache, DatabaseEntry, SingleFileMessagePackBackingStore, is_stored_at_stamp};
#[derive(Clone, Debug, PartialEq, Eq, DatabaseEntry)]
#[cultcache(type = "tests.stamp_note", schema = "tests.stamp_note")]
struct Note {
#[cultcache(key = 0)]
text: String,
}
fn rows() -> Vec<(String, bool)> {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/stored-at-stamp/stamps.msgpack");
rmp_serde::from_slice(&std::fs::read(path).unwrap()).unwrap()
}
#[test]
fn the_stamp_check_agrees_with_every_vector_row() {
let rows = rows();
assert!(rows.len() > 30, "the vector was not read");
for (text, parses) in rows {
assert_eq!(is_stored_at_stamp(&text), parses, "{text:?}");
}
}
#[test]
fn a_raw_envelope_that_is_no_stamp_is_refused_and_nothing_is_stored() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("stamps.cc");
let mut cache = CultCache::new();
cache.register_entry_type::<Note>().unwrap();
cache.add_generic_backing_store(SingleFileMessagePackBackingStore::new(&path)).unwrap();
cache.pull_all_backing_stores().unwrap();
for (text, parses) in rows() {
if parses {
continue;
}
let (mut entry, _) = cache.prepare_entry("a", &Note { text: "peer".into() }).unwrap();
entry.stored_at = text.clone();
let error = cache.put_raw_envelope(entry.clone()).unwrap_err();
assert!(format!("{error:#}").contains("stored_at"), "{text:?}: {error:#}");
let error = cache.put_prepared_batch(vec![entry]).unwrap_err();
assert!(format!("{error:#}").contains("stored_at"), "{text:?}: {error:#}");
assert!(cache.get::<Note>("a").unwrap().is_none(), "{text:?} was stored");
assert!(!path.exists() || SingleFileMessagePackBackingStore::new(&path).pull_all().unwrap().is_empty(), "{text:?} reached the file");
}
// Refused writes left the store writable: a stamped one lands under the stamp it carries.
let (mut entry, _) = cache.prepare_entry("b", &Note { text: "stamped".into() }).unwrap();
entry.stored_at = "2026-11-07T00:00:00Z".into();
cache.put_raw_envelope(entry).unwrap();
assert_eq!(cache.get::<Note>("b").unwrap().unwrap().text, "stamped");
assert!(cache.get::<Note>("a").unwrap().is_none());
}

View file

@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultcache-ts` are recorded here, following
### Added
- `isStoredAtStamp(text)` (package and browser entries): whether a `storedAt` is a stamp, the one shape pinned in `tests/vectors/stored-at-stamp/` (an RFC 3339 date-time with `T`, an optional 1 to 9 digit fraction, `Z` or `+hh:mm`/`-hh:mm`, a real calendar date, seconds 00-59, years 0001-9999). cultnet-ts and cultmesh-ts judge a peer's put by it (variants:ruling:raw-put-without-stamp-refused).
- `CacheBackingStore` gains the optional `read(key)` (the record at the key with its content version, from one read) and `commitIf(entry, expectedVersion)` (write only if the version still holds, under the store's write lock; returns the storedAt written). The single-file store implements both. The content version is the lowercase hex SHA-256 of the record's persisted encoding `[key, schemaId, storedAt, payload]`, equal to the C# reference's on `tests/vectors/content-version`. A store without them still works for everything except `update`.
- `CultCacheUpdateResult` and `CultCacheUpdateOptions` (`maxAttempts`, default 5), exported from the package entry and the browser entry.

View file

@ -16,6 +16,7 @@ export {
} from "./document";
export { CultCachePayloadDepthError, MAX_PAYLOAD_DEPTH } from "./payload-depth";
export type { DocumentPayload } from "./document";
export { isStoredAtStamp } from "./stored-at";
export { SchemaConflictError, StoreUnreadableError } from "./store-format";
export type {
AnyCultCacheDocumentDefinition,

View file

@ -0,0 +1,33 @@
// The one shape a `storedAt` stamp has (tests/vectors/stored-at-stamp/README.md; reference `parse_stored_at` in cultcache-py): an
// RFC 3339 date-time `YYYY-MM-DDThh:mm:ss`, an optional fraction of 1 to 9 digits, and `Z` or `+hh:mm` / `-hh:mm` (hours 00-23,
// minutes 00-59). A real calendar date, seconds 00-59, and an instant within years 0001-9999. Checked here, not left to
// `Date.parse`, which accepts shapes this one does not and whose accepted set differs by engine.
const STAMP = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-9]{2})(?:\.[0-9]{1,9})?(?:Z|([+-])([01][0-9]|2[0-3]):([0-5][0-9]))$/;
const MS_PER_MINUTE = 60_000;
/** True when `text` is a string that is a stamp. Anything else (missing, empty, wrong type, other shapes) is not. */
export function isStoredAtStamp(text: unknown): text is string {
if (typeof text !== "string") return false;
const shape = STAMP.exec(text);
if (shape === null) return false;
const [year, month, day, hour, minute, second] = shape.slice(1, 7).map(Number) as [number, number, number, number, number, number];
const leap = (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
const daysInMonth = [31, leap ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][month - 1];
if (year < 1 || month < 1 || month > 12 || day < 1 || day > (daysInMonth as number) || hour > 23 || minute > 59 || second > 59) {
return false;
}
// The UTC instant must stay within years 0001-9999, so an offset can push a boundary date out of range.
const local = new Date(0);
local.setUTCFullYear(year, month - 1, day);
local.setUTCHours(hour, minute, second, 0);
const offsetMinutes = shape[7] === undefined ? 0 : (shape[7] === "-" ? -1 : 1) * (Number(shape[8]) * 60 + Number(shape[9]));
const utcMs = local.getTime() - offsetMinutes * MS_PER_MINUTE;
const first = new Date(0);
first.setUTCFullYear(1, 0, 1);
first.setUTCHours(0, 0, 0, 0);
const last = new Date(0);
last.setUTCFullYear(9999, 11, 31);
last.setUTCHours(23, 59, 59, 999);
return utcMs >= first.getTime() && utcMs <= last.getTime();
}

View file

@ -0,0 +1,20 @@
// isStoredAtStamp agrees with every row of tests/vectors/stored-at-stamp/stamps.msgpack, the one shape a peer's storedAt must parse as.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { decode } from "@msgpack/msgpack";
import { isStoredAtStamp } from "../src";
// dist-test/test -> the repository root.
export const STAMP_VECTOR = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "stored-at-stamp", "stamps.msgpack");
test("isStoredAtStamp answers every row of the stored-at-stamp vector", () => {
const rows = decode(readFileSync(STAMP_VECTOR)) as [string, boolean][];
assert.ok(rows.length > 20, "the vector was read");
for (const [text, parses] of rows) assert.equal(isStoredAtStamp(text), parses, JSON.stringify(text));
});
test("a stamp is a string: anything else is not one", () => {
for (const value of [undefined, null, 0, 1_700_000_000_000, true, {}, [], new Date()]) assert.equal(isStoredAtStamp(value), false);
});

View file

@ -12,7 +12,7 @@ from typing import Any, Callable
from cultcache_py import CultCache, CultCacheEnvelope, SingleFileMessagePackBackingStore
from cultcache_py.cache import CultCacheError, index_text
from cultcache_py.documents import DocumentDefinition, extract_value
from cultcache_py.stores import parse_stored_at
from cultnet_py.replication import raw_record_to_envelope
from cultnet_py import (
CultNetAppliedRecord,
CultNetFileShardMutationLogStore,
@ -724,19 +724,8 @@ class CultMeshDatabase:
document = self._raw_put_document(message)
if document is None:
return None
document_record = message["document"]
# A peer's put is stored under the stamp it carries, so one that is no stamp is refused: nothing is minted for it.
if parse_stored_at(document_record.get("storedAt")) is None:
raise ValueError("A raw put must carry a storedAt stamp.")
# One id: the local schema id, under which the cache stores the record.
return document, CultCacheEnvelope(
key=str(document_record["recordKey"]),
type=document.type,
schema_id=document.catalog_entry().schema_id,
payload=bytes(document_record["payload"]),
stored_at=document_record["storedAt"],
catalog_entry=document.catalog_entry(),
)
# One id: the local schema id, under which the cache stores the record. The stamp is judged where a record becomes an envelope.
return document, raw_record_to_envelope(document, document.catalog_entry().schema_id, message["document"])
@staticmethod
def _served_record(envelope: CultCacheEnvelope) -> CultNetRawDocumentRecord:

View file

@ -0,0 +1,212 @@
"""A record a peer replicates to this node is stored under the stamp it carries, so one with no stamp is refused where it becomes an envelope.
The stamp shape is tests/vectors/stored-at-stamp/stamps.msgpack, held to the same answers as the raw put of the server
(variants:ruling:raw-put-without-stamp-refused). A snapshot response is refused whole with ValueError before anything is stored (as a record of an unregistered schema is refused whole). A shard log is
refused whole too: every put it would apply is judged for its stamp before the first entry is stored (a put of an unregistered schema is skipped, as ever, and is not judged). The refusal of a put is
message_invalid (field `document`), tested in test_remote_write_authority.py.
"""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
from typing import Any
import msgpack
from cultcache_py import define_database_entry_type
from cultcache_py.stores import parse_stored_at
from cultmesh_py import CultMeshGrantAllWrites, CultMeshServerPeer, create_node
from cultmesh_py.client import CultMeshDocumentSubscription
from cultmesh_py.server import CultMeshLocalServer
from cultnet_py import CultNetDatabaseChange, document_put_raw
SCHEMA_ID = "sha256:stampless-note"
SHARD_ID = "stampless-shard"
PEER = CultMeshServerPeer("127.0.0.1:50000", "tcp")
VECTOR = Path(__file__).resolve().parents[3] / "tests/vectors/stored-at-stamp/stamps.msgpack"
ROWS: list[tuple[str, bool]] = [(text, parses) for text, parses in msgpack.unpackb(VECTOR.read_bytes(), raw=False)]
# The vector's texts, plus the shapes a record can lack a stamp in: the field missing, and not a string at all.
REFUSED: list[tuple[str, Any]] = [(f"vector {text!r}", text) for text, parses in ROWS if not parses] + [
("missing", None),
("not a string", 12345),
]
NOTE = define_database_entry_type(
"tests.stampless_note",
[("schema_version", 0), ("text", 1)],
schema_id=SCHEMA_ID,
schema_name="tests.stampless_note",
schema_version="tests.stampless_note.v1",
)
def record_of(key: str, text: str, stamp: Any = "2026-11-07T00:00:00Z") -> dict[str, Any]:
record = {
"schemaId": SCHEMA_ID,
"recordKey": key,
"payloadEncoding": "messagepack",
"payload": NOTE.encode_payload({"schema_version": "tests.stampless_note.v1", "text": text}),
}
if stamp is not None:
record["storedAt"] = stamp
return record
def snapshot_of(*records: dict[str, Any]) -> dict[str, Any]:
return {"schemaVersion": "cultnet.snapshot_response_raw.v0", "messageId": "snapshot", "documents": list(records)}
def shard_log_of(*records: dict[str, Any]) -> dict[str, Any]:
entries = [
{
"sequence": index + 1,
"committedAt": "2026-11-07T00:00:00Z",
"changeKind": "added",
"put": document_put_raw(
message_id=f"put-{index}", key=record["recordKey"], schema_id=SCHEMA_ID, stored_at="2026-11-07T00:00:00Z",
payload=record["payload"], shard_id=SHARD_ID, shard_epoch=1,
).to_wire() | {"document": record},
}
for index, record in enumerate(records)
]
return {
"schemaVersion": "cultnet.shard_log_response.v0", "messageId": "log", "shardId": SHARD_ID, "shardEpoch": 1,
"entries": entries, "resyncRequired": False,
}
class StampVectorTests(unittest.TestCase):
def test_python_parses_every_row_of_the_vector_as_the_vector_says(self) -> None:
self.assertGreater(len(ROWS), 30)
for text, parses in ROWS:
with self.subTest(text=text):
self.assertEqual(parse_stored_at(text) is not None, parses)
def test_every_vector_row_through_the_server_put_handler_is_stored_or_refused_as_the_vector_says(self) -> None:
with tempfile.TemporaryDirectory() as directory:
node = create_node(Path(directory) / "node.cc", runtime_id="vector-server")
node.database.register_document(NOTE)
server = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites())
sized = CultMeshLocalServer(node=node, port=0, write_authority=CultMeshGrantAllWrites(), max_snapshot_bytes=10**6)
for index, (text, parses) in enumerate(ROWS):
for label, handler, over_rudp in (("plain", server, False), ("sized", sized, False), ("rudp", server, True)):
with self.subTest(text=text, path=label):
key = f"vector:{index}:{label}"
message = document_put_raw(
message_id="wire", key=key, schema_id=SCHEMA_ID, stored_at=text,
payload=record_of(key, "peer")["payload"], shard_id=SHARD_ID, shard_epoch=1,
).to_wire()
replies = handler._handle_connection_message(message, {}, over_rudp=over_rudp, peer=PEER)
if parses:
self.assertEqual(replies, [])
self.assertEqual(node.cache.get(NOTE, key)["text"], "peer")
else:
self.assertEqual([(r["code"], r["details"]["field"]) for r in replies], [("message_invalid", "document")])
self.assertIsNone(node.cache.get(NOTE, key))
class StamplessSnapshotTests(unittest.TestCase):
def build(self, directory: str) -> Any:
node = create_node(Path(directory) / "node.cc", runtime_id="stampless-client")
node.database.register_document(NOTE)
node.database.pull()
return node
def test_a_snapshot_with_a_stampless_record_is_refused_whole_and_the_store_survives_a_reopen(self) -> None:
for label, stamp in REFUSED:
with self.subTest(label), tempfile.TemporaryDirectory() as directory:
node = self.build(directory)
good, bad = record_of("note:good", "good"), record_of("note:bad", "bad", stamp)
for order in ((good, bad), (bad, good)):
with self.assertRaises(ValueError):
node.database.apply_snapshot_response(snapshot_of(*order))
self.assertIsNone(node.cache.get(NOTE, "note:good"), "a refused snapshot stores none of its records")
self.assertIsNone(node.cache.get(NOTE, "note:bad"))
# The store file reads back, and is still writable by a snapshot of stamped records.
reopened = self.build(directory)
self.assertIsNone(reopened.cache.get(NOTE, "note:bad"))
applied = reopened.database.apply_snapshot_response(snapshot_of(good))
self.assertEqual([a.record_key for a in applied], ["note:good"])
self.assertEqual(self.build(directory).cache.get(NOTE, "note:good")["text"], "good")
def test_a_snapshot_of_every_stamped_vector_row_is_stored(self) -> None:
with tempfile.TemporaryDirectory() as directory:
node = self.build(directory)
rows = [(index, text) for index, (text, parses) in enumerate(ROWS) if parses]
node.database.apply_snapshot_response(snapshot_of(*[record_of(f"stamped:{i}", "ok", text) for i, text in rows]))
self.assertEqual(len(self.build(directory).cache.get_all(NOTE)), len(rows))
for index, _ in rows:
self.assertEqual(node.cache.get(NOTE, f"stamped:{index}")["text"], "ok")
class StamplessShardLogTests(unittest.TestCase):
def test_a_shard_log_put_with_no_stamp_is_refused_and_the_store_stays_writable(self) -> None:
for label, stamp in REFUSED:
with self.subTest(label), tempfile.TemporaryDirectory() as directory:
node = create_node(Path(directory) / "node.cc", runtime_id="stampless-log")
node.database.register_document(NOTE)
with self.assertRaises(ValueError):
node.database.apply_shard_log_response(shard_log_of(record_of("note:bad", "bad", stamp)))
self.assertIsNone(node.cache.get(NOTE, "note:bad"))
reopened = create_node(Path(directory) / "node.cc", runtime_id="stampless-log")
reopened.database.register_document(NOTE)
reopened.database.pull()
reopened.database.apply_shard_log_response(shard_log_of(record_of("note:good", "good")))
self.assertEqual(reopened.cache.get(NOTE, "note:good")["text"], "good")
again = create_node(Path(directory) / "node.cc", runtime_id="stampless-log")
again.database.register_document(NOTE)
again.database.pull()
self.assertEqual(again.cache.get(NOTE, "note:good")["text"], "good")
def test_a_shard_log_with_a_bad_entry_in_the_middle_stores_nothing_and_tells_no_watcher(self) -> None:
for label, stamp in REFUSED:
with self.subTest(label), tempfile.TemporaryDirectory() as directory:
node = create_node(Path(directory) / "node.cc", runtime_id="stampless-multi")
node.database.register_document(NOTE)
seen: list[Any] = []
node.database.watch(seen.append)
log = shard_log_of(record_of("note:a", "a"), record_of("note:b", "b", stamp), record_of("note:c", "c"))
with self.assertRaises(ValueError):
node.database.apply_shard_log_response(log)
for key in ("note:a", "note:b", "note:c"):
self.assertIsNone(node.cache.get(NOTE, key), f"{key} was not stored")
self.assertEqual(seen, [], "no watcher heard of a refused log")
node.database.apply_shard_log_response(shard_log_of(record_of("note:a", "a"), record_of("note:c", "c")))
self.assertEqual([node.cache.get(NOTE, k)["text"] for k in ("note:a", "note:c")], ["a", "c"])
self.assertEqual(len(seen), 2, "a later good log applies and is heard")
class _StubSubscription:
def __init__(self, change: CultNetDatabaseChange) -> None:
self._change = change
def read_next_change(self) -> CultNetDatabaseChange:
return self._change
class StamplessClientReplayTests(unittest.TestCase):
def test_a_subscribed_put_with_no_stamp_is_refused_by_read_next_change_and_nothing_is_stored(self) -> None:
for label, stamp in REFUSED:
with self.subTest(label), tempfile.TemporaryDirectory() as directory:
node = create_node(Path(directory) / "node.cc", runtime_id="stampless-subscriber")
node.database.register_document(NOTE)
subscription = CultMeshDocumentSubscription(client=None, database=node.database, document=NOTE, key="note:bad") # type: ignore[arg-type]
def deliver(record: dict[str, Any]) -> Any:
message = {
"schemaVersion": "cultnet.database_change_raw.v0", "messageId": "change", "changeKind": "added",
"schemaId": SCHEMA_ID, "recordKey": record["recordKey"], "document": record,
}
subscription._subscription = _StubSubscription(CultNetDatabaseChange.from_wire(message)) # type: ignore[assignment]
return subscription.read_next_change()
with self.assertRaises(ValueError):
deliver(record_of("note:bad", "bad", stamp))
self.assertIsNone(node.cache.get(NOTE, "note:bad"))
self.assertEqual(deliver(record_of("note:bad", "stamped")).value["text"], "stamped")
if __name__ == "__main__":
unittest.main()

View file

@ -1301,6 +1301,30 @@ mod tests {
Ok(())
}
/// The node's store is the second owner of the stamp rule: a snapshot document whose `storedAt` is no stamp refuses
/// the whole pull, nothing is minted or stored, and the node stays writable.
#[test]
fn a_snapshot_document_whose_stored_at_is_no_stamp_refuses_the_pull_and_stores_nothing() -> Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("m.cc");
let mut node = CultMesh::create_node(&path, TestDocuments, Default::default())?;
let plain = |body: &str| rmp_serde::to_vec(&(body,));
for bad in ["", "yesterday", "2026-01-01T00:00:00", "2026-01-01t00:00:00z", "2026-01-01T00:00:60Z"] {
let mut stampless = raw_note("bad", plain("bad")?);
stampless.stored_at = bad.to_string();
let error = node
.apply_raw_snapshot_documents(vec![raw_note("good", plain("good")?), stampless])
.unwrap_err();
assert!(format!("{error:#}").contains("stored_at"), "{bad:?}: {error:#}");
assert!(node.get::<Note>("good")?.is_none() && node.get::<Note>("bad")?.is_none(), "{bad:?}");
}
assert_eq!(node.apply_raw_snapshot_documents(vec![raw_note("after", plain("after")?)])?, 1);
let reopened = CultMesh::create_node(&path, TestDocuments, Default::default())?;
assert_eq!(reopened.get_required::<Note>("after")?.body, "after");
assert!(reopened.get::<Note>("good")?.is_none());
Ok(())
}
#[test]
fn a_snapshot_applied_to_a_stale_node_keeps_the_records_another_writer_added() -> Result<()> {
let temp = tempfile::tempdir()?;

View file

@ -69,7 +69,8 @@ pub struct CultMeshRudpSnapshotQuery {
pub trait CultMeshRudpRawDocumentSink {
/// Whether this put may be written, decided without writing it. A receipt-capable session asks it only
/// the first time it sees a message id; a resend is answered from what the session remembers
/// (write-receipt.md, rule 5). The default admits everything and leaves the decision to
/// (write-receipt.md, rule 5). A legacy session asks it only for a put whose `storedAt` is no stamp, so the
/// authority's refusal comes before the stamp's. The default admits everything and leaves the decision to
/// `accept_raw_document`.
fn authorize_raw_document(&mut self, _receipt: &CultMeshRudpRawDocumentReceipt) -> Result<()> {
Ok(())
@ -293,6 +294,9 @@ pub enum CultMeshRudpRejectionReason {
DocumentMissing,
/// A receipt-capable session's put or delete has a null or missing `schemaId`.
SchemaIdMissing,
/// A put's `storedAt` is not a stamp (`cultcache_rs::is_stored_at_stamp`). It is judged after the sink's
/// authorization and before the document is measured or the sink commits it: nothing is minted or stored.
StoredAtNotStamp,
}
impl std::fmt::Display for CultMeshRudpRejectionReason {
@ -307,6 +311,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason {
Self::ChannelNotReliable => f.write_str("a receipt session's write arrived on an unreliable channel"),
Self::DocumentMissing => f.write_str("the write has no document"),
Self::SchemaIdMissing => f.write_str("the write has no schemaId"),
Self::StoredAtNotStamp => f.write_str("the document's storedAt is not a stamp"),
Self::DocumentUnservable {
response_bytes,
max_snapshot_response_bytes,
@ -347,7 +352,7 @@ impl std::fmt::Display for CultMeshRudpRejectionReason {
/// The texts a refused peer is sent, one per rejection variant. They are fixed:
/// a sink's or source's error text can quote what a peer sent, so it stays with
/// the caller that receives the rejection and never reaches the wire.
pub(crate) const REFUSAL_TEXTS: [&str; 17] = [
pub(crate) const REFUSAL_TEXTS: [&str; 18] = [
"the catalog refused the document",
"the document can never be served",
"the snapshot source failed",
@ -365,6 +370,7 @@ pub(crate) const REFUSAL_TEXTS: [&str; 17] = [
"receipt sessions require the reliable channel",
"document is required",
"schemaId is required",
"document must carry a storedAt stamp",
];
impl CultMeshRudpRejectionReason {
@ -393,7 +399,8 @@ impl CultMeshRudpRejectionReason {
13 => Self::MessageIdTooLong,
14 => Self::ChannelNotReliable,
15 => Self::DocumentMissing,
_ => Self::SchemaIdMissing,
16 => Self::SchemaIdMissing,
_ => Self::StoredAtNotStamp,
}
}
@ -416,6 +423,7 @@ impl CultMeshRudpRejectionReason {
Self::ChannelNotReliable => 14,
Self::DocumentMissing => 15,
Self::SchemaIdMissing => 16,
Self::StoredAtNotStamp => 17,
}
}
}
@ -498,6 +506,7 @@ impl CultMeshRudpApplicationRejection {
(CultMeshRudpRejectionReason::MessageIdTooLong, _) => message_invalid_message("messageId", text),
(CultMeshRudpRejectionReason::DocumentMissing, _) => message_invalid_message("document", text),
(CultMeshRudpRejectionReason::SchemaIdMissing, _) => message_invalid_message("schemaId", text),
(CultMeshRudpRejectionReason::StoredAtNotStamp, _) => message_invalid_message("document", text),
(CultMeshRudpRejectionReason::ChannelNotReliable, _) => message_invalid_message("channel", text),
(CultMeshRudpRejectionReason::WriteLimitReached, _) => CultNetMessage::Error {
error: format!("limit_reached: {text}"),
@ -1601,11 +1610,19 @@ where
receipt: CultMeshRudpRawDocumentReceipt,
authorize: bool,
) -> std::result::Result<(), CultMeshRudpRejectionReason> {
if authorize {
let stamped = cultcache_rs::is_stored_at_stamp(&receipt.document.stored_at);
// The authority speaks first. A legacy session's sink decides at `accept_raw_document`, so it is asked here
// only when the stamp is bad: an ungranted peer is refused `write_not_authorized`, not told its stamp is wrong.
if authorize || !stamped {
if let Err(error) = self.sink.authorize_raw_document(&receipt) {
return Err(CultMeshRudpRejectionReason::SinkRefused(format!("{error:#}")));
}
}
// A peer's put is stored under the stamp it carries, so one that is no stamp is refused before anything is
// measured, minted or handed to the sink.
if !stamped {
return Err(CultMeshRudpRejectionReason::StoredAtNotStamp);
}
let document = &receipt.document;
// Admit only what some snapshot request can return: the smallest
// response that could carry this document is the record the snapshot

View file

@ -3546,6 +3546,10 @@ fn blank_means_exactly_the_unicode_white_space_code_points_in_every_field() -> R
for (index, (hex, blank)) in space.iter().map(|h| (h, true)).chain(not_space.iter().map(|h| (h, false))).enumerate() {
let hex = hex.as_str().unwrap();
for (field_index, field) in fields.iter().enumerate() {
// A storedAt must be a stamp: a non-blank one that is no stamp is not a name (write-receipt.md, Required fields).
if !blank && *field == "storedAt" {
continue;
}
// A session that is sent refusals is allowed to end, so each code point and field gets its own.
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
@ -3626,3 +3630,177 @@ fn an_unreadable_frame_on_a_receipt_connection_is_sent_exactly_the_fixed_text()
);
Ok(())
}
// ---- A peer's `storedAt` must be a stamp (variants:ruling:raw-put-without-stamp-refused) ----
//
// `tests/vectors/stored-at-stamp/stamps.msgpack`: `[text, parses]` rows. Every row that is no stamp is refused and
// nothing reaches the sink. A blank text names nothing, so it is refused as a frame that does not read (field
// "message", write-receipt.md, Required fields); any other text that is no stamp is refused field "document".
fn stamp_rows() -> Vec<(String, bool)> {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/vectors/stored-at-stamp/stamps.msgpack");
rmp_serde::from_slice(&std::fs::read(path).unwrap()).unwrap()
}
fn is_blank(text: &str) -> bool {
text.chars().all(char::is_whitespace)
}
/// A put of `key` whose `storedAt` is `stamp` as it would arrive: a blank one is swapped into the bytes, since the
/// encoder refuses it.
fn put_stamped_bytes(id: &str, key: &str, payload: Vec<u8>, stamp: &str) -> Result<Vec<u8>> {
let mut record = document(key, payload);
record.stored_at = if is_blank(stamp) { "zz-stamp-zz".into() } else { stamp.into() };
let bytes = encode_cultnet_message_to_vec(
&CultNetMessage::DocumentPutRaw { message_id: id.into(), document: record },
CultNetWireContract::CultNetSchemaV0,
)?;
Ok(if is_blank(stamp) { swap_fixstr(&bytes, "zz-stamp-zz", stamp) } else { bytes })
}
#[test]
fn a_receipt_session_put_that_is_no_stamp_is_refused_for_every_vector_row_and_the_store_stays_writable() -> Result<()> {
let rows = stamp_rows();
assert!(rows.len() > 30);
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = client(server.local_addr()?, 700)?;
connect(&mut server, &mut [&mut peer])?;
send_reliable(&mut peer, &receipt_hello())?;
assert_eq!(frames_after(&mut server, &mut peer, 1)?, vec![server_hello()]);
let mut expected = Vec::new();
let mut answers = Vec::new();
for (index, (text, parses)) in rows.iter().enumerate() {
let id = format!("r{index}");
peer.send_reliable("schema", put_stamped_bytes(&id, &format!("note:{index}"), vec![1, 2, 3], text)?)?;
expected.push((id, text.clone(), *parses));
// One write in flight at a time: the answer arrives before the next row is sent.
answers.extend(frames_after(&mut server, &mut peer, 1)?);
}
peer.send_reliable("schema", valid_put("after"))?;
answers.extend(frames_after(&mut server, &mut peer, 1)?);
for (answer, (id, text, parses)) in answers.iter().zip(&expected) {
if *parses {
assert_eq!(*answer, admitted(id, &id.replace('r', "note:")), "{text:?}");
continue;
}
assert_eq!(refusal_of(answer), (Some(CultNetErrorCode::MessageInvalid), Some(id.clone())), "{text:?}");
let field = if is_blank(text) { "message" } else { "document" };
assert_eq!(error_text_and_field(answer).1, Some(field.into()), "{text:?}");
}
// Every stamp row was stored exactly as sent; no refused row was stored; the put after them all was.
let state = sink.0.lock().unwrap();
let stored: Vec<&str> = state.receipts.iter().map(|receipt| receipt.document.stored_at.as_str()).collect();
let stamps: Vec<&str> = rows.iter().filter(|(_, parses)| *parses).map(|(text, _)| text.as_str()).collect();
assert_eq!(&stored[..stamps.len()], &stamps[..]);
assert_eq!(stored.len(), stamps.len() + 1, "only the stamped puts and the final put were stored");
assert_eq!(answers.last(), Some(&admitted("put-after", "after")));
Ok(())
}
#[test]
fn a_put_with_no_storedat_key_is_refused_and_a_resend_replays_the_refusal() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = client(server.local_addr()?, 701)?;
connect(&mut server, &mut [&mut peer])?;
send_reliable(&mut peer, &receipt_hello())?;
let missing = swap_fixstr(&valid_put("a"), "storedAt", "storedAx");
peer.send_reliable("schema", missing)?;
// The same garbage stamp twice under one id: the refusal is remembered (by index) and replays as it was.
peer.send_reliable("schema", put_stamped_bytes("g1", "note:g", vec![1], "yesterday")?)?;
peer.send_reliable("schema", put_stamped_bytes("g1", "note:g", vec![1], "yesterday")?)?;
let answers = answers_after(&mut server, &mut peer, 3)?;
assert_eq!(error_text_and_field(&answers[0]).1, Some("message".into()));
assert_eq!(refusal_of(&answers[1]), (Some(CultNetErrorCode::MessageInvalid), Some("g1".into())));
assert_eq!(error_text_and_field(&answers[1]).1, Some("document".into()));
assert_eq!(answers[2], answers[1]);
assert!(sink.0.lock().unwrap().receipts.is_empty());
Ok(())
}
#[test]
fn a_legacy_session_put_that_is_no_stamp_is_refused_for_every_vector_row_and_nothing_is_stored() -> Result<()> {
let sink = Sink::default();
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut connection_id = 800;
for (text, parses) in stamp_rows() {
if parses {
continue;
}
connection_id += 1;
let mut peer = client(server.local_addr()?, connection_id)?;
connect(&mut server, &mut [&mut peer])?;
let receipt = peer.send_reliable("schema", put_stamped_bytes("bad", "note:bad", vec![1, 2, 3], &text)?)?;
let reason = poll_until_rejected_or_stored(&mut server, &mut peer, &sink, 1)?;
if is_blank(&text) {
assert!(matches!(reason, Some(CultMeshRudpRejectionReason::MessageUnreadable(_))), "{text:?}: {reason:?}");
} else {
assert_eq!(reason, Some(CultMeshRudpRejectionReason::StoredAtNotStamp), "{text:?}");
let (error, code, details) = typed_refusal_seen_by(&mut peer, &receipt, Some(&mut server))?;
assert_eq!(code, Some(CultNetErrorCode::MessageInvalid), "{text:?}: {error}");
assert_eq!(details.and_then(|d| d.field), Some("document".into()), "{text:?}");
}
assert!(sink.0.lock().unwrap().receipts.is_empty(), "{text:?} reached the sink");
}
// The server and its sink are still writable.
connection_id += 1;
let mut peer = client(server.local_addr()?, connection_id)?;
connect(&mut server, &mut [&mut peer])?;
stored_after_poll(&mut server, &mut peer, &sink, 1)?;
Ok(())
}
/// The size gate measures the document as a snapshot would serve it: a put over the bound that is also no stamp is
/// refused for its stamp first, and nothing is measured for one that is.
#[test]
fn the_stamp_is_judged_before_the_size_limit() -> Result<()> {
let over = document("big", vec![7; 101]);
let options = CultMeshRudpDocumentServerOptions {
max_snapshot_response_bytes: served_alone_bytes(&over)? - 1,
..Default::default()
};
let sink = Sink::default();
let mut server = server(options, Clock::new(70_000), sink.clone(), Source::default())?;
let mut peer = client(server.local_addr()?, 900)?;
connect(&mut server, &mut [&mut peer])?;
peer.send_reliable("schema", put_stamped_bytes("o1", "big", vec![7; 101], "yesterday")?)?;
assert_eq!(
poll_until_rejected_or_stored(&mut server, &mut peer, &sink, 1)?,
Some(CultMeshRudpRejectionReason::StoredAtNotStamp)
);
let mut stamped = client(server.local_addr()?, 901)?;
connect(&mut server, &mut [&mut stamped])?;
stamped.send_reliable("schema", put_stamped_bytes("o2", "big", vec![7; 101], "2026-06-25T00:00:00Z")?)?;
assert!(matches!(
poll_until_rejected_or_stored(&mut server, &mut stamped, &sink, 1)?,
Some(CultMeshRudpRejectionReason::DocumentUnservable { .. })
));
assert!(sink.0.lock().unwrap().receipts.is_empty());
Ok(())
}
/// An ungranted peer is refused `write_not_authorized` before its stamp is judged, on a receipt session and on a
/// legacy one, and the authority is asked about the write.
#[test]
fn an_ungranted_peer_is_refused_for_authority_before_its_stamp_is_judged() -> Result<()> {
let sink = Sink::denying(&["secret"]);
let mut server = server(Default::default(), Clock::new(70_000), sink.clone(), Source::default())?;
let mut receipt_peer = client(server.local_addr()?, 950)?;
connect(&mut server, &mut [&mut receipt_peer])?;
send_reliable(&mut receipt_peer, &receipt_hello())?;
receipt_peer.send_reliable("schema", put_stamped_bytes("a1", "secret", vec![1], "yesterday")?)?;
let answers = answers_after(&mut server, &mut receipt_peer, 1)?;
assert_eq!(refusal_of(&answers[0]), (Some(CultNetErrorCode::WriteNotAuthorized), Some("a1".into())));
assert_eq!(sink.0.lock().unwrap().authorized, vec!["a1".to_string()]);
let mut legacy_peer = client(server.local_addr()?, 951)?;
connect(&mut server, &mut [&mut legacy_peer])?;
legacy_peer.send_reliable("schema", put_stamped_bytes("a2", "secret", vec![1], "yesterday")?)?;
assert!(matches!(
poll_until_rejected_or_stored(&mut server, &mut legacy_peer, &sink, 1)?,
Some(CultMeshRudpRejectionReason::SinkRefused(_))
));
assert!(sink.0.lock().unwrap().receipts.is_empty());
Ok(())
}

View file

@ -11,6 +11,7 @@ All notable changes to `@gamecult/cultmesh-ts` are recorded here, following
### Breaking
- `createRudpDocumentServer` refuses a peer put whose `storedAt` is missing, empty or not a stamp (`isStoredAtStamp`, the shape pinned in `tests/vectors/stored-at-stamp/`) with `message_invalid` naming `document`, after the write authority has been asked and before the put is sized; `onDocumentPutRaw` is not called, so nothing is stored and no stamp is minted (variants:ruling:raw-put-without-stamp-refused). `CultMeshRudpDocumentPut.storedAt` is the stamp the peer sent, never a server clock; a document synced from a peer with a stampless record is refused by the registry.
- `publishRudpDocumentOnce` no longer treats a window with no refusal as published: a put nothing settled in its result window (500 ms, or 5000 ms against a receipt-capable server) rejects `CultMeshRudpWriteUnconfirmedError`, and a legacy server's echo of the put's id is not admission. It now resolves `"admitted"` (only on a `write_admitted`) or `"sent"`. **Migration:** a caller whose server cannot answer a write yet (for example Odin, until it is receipt-capable) passes `sendOnly: true` per call and treats `"sent"` as "sent", never as admitted. Receipts stay opt-in (`writeReceipts: true`); neither mode is the default.
- The package is renamed `cultmesh-ts` to `@gamecult/cultmesh-ts` (the repo already scopes `@gamecult/cultcache-ts`), so the unscoped name cannot be claimed by someone else. Import from `@gamecult/cultmesh-ts` and its subpaths; the API is unchanged. Depends on `@gamecult/cultnet-ts`.
- `asSchemaAlias` is removed from `CultMeshDocumentHandle`, `CultMeshCollectionHandle` and their bound forms. Read the handle by the schema you mean.

View file

@ -5,6 +5,7 @@ import {
CultCache,
SchemaConflictError,
SingleFileMessagePackBackingStore,
isStoredAtStamp,
schemaIdentityOf,
type AnyCultCacheDocumentDefinition,
type CacheBackingStore,
@ -5813,6 +5814,12 @@ export class CultMesh {
));
return;
}
// A peer's put is stored under the stamp it carries: one that is no stamp is refused, after the authority
// has spoken and before the put is sized, so the size-limited path never measures it. Nothing is minted.
if (!isStoredAtStamp(message.document.storedAt)) {
sendSchemaMessage(record, cultNetMessageInvalidError("document", "Raw put documents must carry a storedAt stamp."));
return;
}
}
if (options.onDocumentPutRaw) {
const unservable = unservablePut(message);
@ -6635,7 +6642,7 @@ function normalizeRudpDocumentPut(
return {
schemaId: document.schemaId,
recordKey: document.recordKey,
storedAt: document.storedAt ?? new Date().toISOString(),
storedAt: document.storedAt,
sourceRuntimeId: document.sourceRuntimeId ?? null,
sourceAgentId: document.sourceAgentId ?? null,
sourceRole: document.sourceRole ?? null,

View file

@ -3423,11 +3423,12 @@ const servedBoundRegistry = () =>
/**
* A put's own storedAt, kept by the served record and replaced in the receipt by the server's
* 24-character clock. `SERVED_STORED_AT` is long, so an echoed receipt is smaller than the served
* reply; `RECEIPT_STORED_AT` is short, so an echoed receipt is the larger reply, which shows that
* 24-character clock. `SERVED_STORED_AT` is as long as a stamp can be (a stamp is judged before a put is
* sized, so padding one is refused), so an echoed receipt is smaller than the served reply;
* `RECEIPT_STORED_AT` is short, so an echoed receipt is the larger reply, which shows that
* admission sizes only the served reply.
*/
const SERVED_STORED_AT = `2026-09-30T00:00:00.000Z${"~".repeat(200)}`;
const SERVED_STORED_AT = "2026-09-30T00:00:00.123456789+00:00"; // the longest a stamp can be, 35 characters
const RECEIPT_STORED_AT = "2026-09-30T00:00:00.000Z";
const SERVED_BOUND_SERVER = "cultmesh-ts-served-bound-server";

View file

@ -7,6 +7,7 @@ import { join } from "node:path";
import test from "node:test";
import { z } from "zod";
import { documentPayloadFromBytes, defineDocumentType } from "@gamecult/cultcache-ts";
import { decode } from "@msgpack/msgpack";
import { decodeRudpPacket } from "@gamecult/cultnet-ts";
import { encodeWireValue } from "@gamecult/cultnet-ts/wire";
import {
@ -310,6 +311,64 @@ test("the refusal on the wire is the pinned error-vectors bytes", async () => {
});
});
// A peer's put is stored under the stamp it carries: every row of the shared vector goes through the real handler. A row that
// is no stamp is refused message_invalid naming `document`, on the plain, the size-limited and (RUDP is this server's only
// transport) the capped path; the handler never runs, so nothing is stored or minted, and a stamped put after it still lands.
const STAMP_VECTOR = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "stored-at-stamp", "stamps.msgpack");
test("a put whose storedAt is no stamp is refused message_invalid (document), for every row of the stamp vector", async () => {
const rows = decode(readFileSync(STAMP_VECTOR)) as [string, boolean][];
const cases: [string, unknown, boolean][] = [...rows.map(([text, parses]): [string, unknown, boolean] => [JSON.stringify(text), text, parses]),
["missing", undefined, false], ["number", 1_700_000_000_000, false]];
for (const extra of [{}, { maxPayloadBytes: 4096 }]) {
await using(await rig(new CultMeshGrantAllWrites(), extra), async (r) => {
let accepted = 0;
for (const [label, storedAt, parses] of cases) {
const message = put(KEY_A) as unknown as { document: Record<string, unknown> };
if (storedAt === undefined) delete message.document.storedAt; else message.document.storedAt = storedAt;
const seen = r.replies.length;
const handled = r.puts.length;
r.peer.send(message as unknown as CultNetMessage);
await sleep(40);
if (parses) {
accepted += 1;
assert.equal(r.puts.length, handled + 1, `${label}: a stamp reaches the handler`);
assert.equal(r.replies.length, seen, label);
} else {
assert.equal(r.puts.length, handled, `${label}: nothing handled, so nothing minted or stored`);
const reply = r.replies[seen] as { schemaVersion?: string; code?: string; details?: { field?: string } } | undefined;
assert.equal(reply?.schemaVersion, "cultnet.error.v0", label);
assert.equal(reply?.code, "message_invalid", label);
assert.equal(reply?.details?.field, "document", label);
}
}
assert.ok(accepted > 0);
assert.deepEqual(r.errors, []);
// The large put under a garbage stamp is refused for its stamp, before it is measured.
const large = put(KEY_A, {}, SCHEMA_ID, new Uint8Array(20_000)) as unknown as { document: Record<string, unknown> };
large.document.storedAt = "yesterday";
r.peer.send(large as unknown as CultNetMessage);
await sleep(60);
const last = r.replies[r.replies.length - 1] as { code?: string; details?: { field?: string } };
assert.equal(last.code, "message_invalid");
assert.equal(last.details?.field, "document");
});
}
// The stamp is judged after the authority: an ungranted peer hears write_not_authorized, aliased or not.
const refusing = new Recording(() => ({ granted: false, reason: "no" }));
await using(await rig(refusing), async (r) => {
const aliased = put(KEY_A, {}, ALIAS) as unknown as { document: Record<string, unknown> };
aliased.document.storedAt = "yesterday";
r.peer.send(aliased as unknown as CultNetMessage);
r.peer.send(put(KEY_B));
await r.settle();
assertRefused(r.replies[0], KEY_A);
assertRefused(r.replies[1], KEY_B);
assert.equal(refusing.requests[0]?.schemaId, SCHEMA_ID, "an aliased put asks under the canonical id");
assert.equal(r.puts.length, 0);
});
});
test("wrong-typed shard fields on a put are judged with null shard fields, then refused as malformed once granted", async () => {
const authority = new Recording(grant);
await using(await rig(authority), async (r) => {

View file

@ -2,6 +2,7 @@
## Unreleased (breaking; version 0.x, so the next minor)
- `raw_record_to_envelope`, where a replicated record becomes an envelope, refuses a record whose `storedAt` is missing, empty or not a stamp (`cultcache_py.stores.parse_stored_at`, the shape pinned by `tests/vectors/stored-at-stamp`) with `ValueError`, nothing minted or stored (`variants:ruling:raw-put-without-stamp-refused`). `apply_raw_document_record`, `apply_raw_snapshot` and `apply_shard_log_response` come through it: a snapshot response with such a record is refused whole before any of its records is stored, as one naming an unregistered schema is; a shard log refuses at that entry, the entries before it already applied. It stored the record under `""`, and the next open of the store raised `CultCacheError` `Missing envelope`. `cultmesh-py`'s raw put builds its envelope through the same function, so there is one owner of the check there; its server gate still answers a peer's put `message_invalid`. A client's `read_next_change` lets the `ValueError` out.
- `decode_witness_artifact_bundle_payload` (and `CultNetWitnessArtifactBundle.from_payload`) read the payload through `cultcache_py.read_payload_tree`: bytes after its first MessagePack value raise `ValueError` `cultnet.witness_artifact_bundle payload has N bytes after its first MessagePack value (refused on read).` instead of `msgpack.ExtraData`, and a payload nested past 256 raises `CultCachePayloadDepthError`. Needs `cultcache-py` 0.4.0, the release that exports `read_payload_tree`: the dependency is now `cultcache-py>=0.4.0` (it was `>=0.2.0`, which admits releases whose `import cultnet_py` fails), and `tests/test_declared_dependency_ranges.py` fails when a declared bound admits a tagged release lacking a name the package imports. The same check found `cultmesh-py` importing `cache.index_text`, which `cultcache-py` 0.2.0 lacks; its bound is `cultcache-py>=0.4.0` too (it has no changelog of its own). `resolve_document_for_raw_record` reads the schema hint from a raw record's first value whether or not bytes follow it, so a record routed by its stamp is refused by count when applied (`ValueError`, in a snapshot, a shard log and a direct apply) like one routed by a registered schema id; `apply_shard_log_response` used to skip it silently as an unknown schema. A tail on a payload whose first value names no registered schema is still `KeyError`, which a shard log skips.
- `apply_raw_document_record` and `apply_raw_snapshot` raise `SchemaConflictError` when a remote record is of another type than the one the cache holds at its key (`cultcache-py` holds one record per key whatever its type); the cache is unchanged, and the caller decides whether to drop the record or the peer. `cultmesh-py`'s server answers such a put with `message_invalid`; its client's `read_next_change` lets the error out.
- `CultNetDatabaseChange` carries `commit_id` and `commit_count` (`commitId`/`commitCount`; None outside a multi-record commit, as C# writes nil): `from_wire` refuses an empty or non-string id and a count that is not an integer of at least 2, `to_wire` writes them when set, and the published `database_change_raw` schema lists both. Python applies no batch. The `simulation_fact` document's `shard`, `frame`, `subject` and `claim_kind` aliases are `roles` (many facts share each); the catalog bytes do not move.

View file

@ -5,6 +5,7 @@ from typing import Any, Iterable
from cultcache_py import CultCache, CultCacheEnvelope
from cultcache_py.documents import DocumentDefinition
from cultcache_py.stores import parse_stored_at
from .shard_log import CultNetShardLogResponse
from .snapshot import CultNetRawDocumentRecord, CultNetRawSnapshotResponse
@ -50,17 +51,29 @@ def apply_raw_document_record(
)
def require_stored_at_stamp(record: dict[str, Any]) -> str:
"""The storedAt a replicated raw record carries; ValueError when it is missing or no stamp. The one place the rule is judged."""
stored_at = record.get("storedAt")
if parse_stored_at(stored_at) is None:
raise ValueError("A replicated raw record must carry a storedAt stamp.")
return stored_at
def raw_record_to_envelope(
document: DocumentDefinition[Any],
schema_id: str,
record: dict[str, Any],
) -> CultCacheEnvelope:
"""The envelope a replicated raw record is stored as; the one place a record becomes an envelope (a direct apply, a snapshot and a
shard log all come through here). A peer's record is stored under the stamp it carries, so one whose storedAt is missing, empty or
no stamp is refused with ValueError and nothing is minted or stored (variants:ruling:raw-put-without-stamp-refused)."""
stored_at = require_stored_at_stamp(record)
return CultCacheEnvelope(
key=str(record["recordKey"]),
type=document.type,
schema_id=schema_id,
payload=bytes(record["payload"]),
stored_at=str(record.get("storedAt") or ""),
stored_at=stored_at,
catalog_entry=document.catalog_entry(),
)
@ -117,6 +130,12 @@ def apply_shard_log_response(
reason = response.get("reason") or "unspecified"
raise ValueError(f"Shard log response requires resync: {reason}")
documents_by_schema_id = schema_document_map(documents)
# The whole log is refused before anything is stored: every put that would be applied is judged for its stamp first.
for entry in response.get("entries", []):
if isinstance(entry, dict) and entry.get("changeKind") in ("added", "updated") and isinstance(entry.get("put"), dict):
record = entry["put"]["document"]
if can_resolve_document_for_raw_record(documents_by_schema_id, str(record["schemaId"]), record):
require_stored_at_stamp(record)
applied: list[CultNetAppliedRecord] = []
for entry in response.get("entries", []):
if not isinstance(entry, dict):

View file

@ -0,0 +1,57 @@
"""raw_record_to_envelope is the one place a replicated raw record becomes an envelope; it refuses a record whose storedAt is no stamp.
The stamp shape is tests/vectors/stored-at-stamp/stamps.msgpack (variants:ruling:raw-put-without-stamp-refused); cultmesh-py tests the
whole replication paths, this tests the function itself.
"""
from __future__ import annotations
import unittest
from pathlib import Path
from typing import Any
import msgpack
from cultcache_py import define_database_entry_type
from cultnet_py.replication import raw_record_to_envelope
VECTOR = Path(__file__).resolve().parents[3] / "tests/vectors/stored-at-stamp/stamps.msgpack"
ROWS: list[tuple[str, bool]] = [(text, parses) for text, parses in msgpack.unpackb(VECTOR.read_bytes(), raw=False)]
NOTE = define_database_entry_type(
"tests.envelope_note",
[("schema_version", 0), ("text", 1)],
schema_id="sha256:envelope-note",
schema_name="tests.envelope_note",
schema_version="tests.envelope_note.v1",
)
def record_with(stamp: Any) -> dict[str, Any]:
record = {
"schemaId": "sha256:envelope-note",
"recordKey": "note:1",
"payload": NOTE.encode_payload({"schema_version": "tests.envelope_note.v1", "text": "t"}),
}
if stamp is not None:
record["storedAt"] = stamp
return record
class RawRecordToEnvelopeStampTests(unittest.TestCase):
def test_every_vector_row_is_an_envelope_or_a_refusal_as_the_vector_says(self) -> None:
self.assertGreater(len(ROWS), 30)
for text, parses in ROWS:
with self.subTest(text=text):
if parses:
self.assertEqual(raw_record_to_envelope(NOTE, "sha256:envelope-note", record_with(text)).stored_at, text)
else:
with self.assertRaises(ValueError):
raw_record_to_envelope(NOTE, "sha256:envelope-note", record_with(text))
def test_a_missing_or_non_string_stamp_is_refused(self) -> None:
for stamp in (None, 12345, b"2026-11-07T00:00:00Z"):
with self.subTest(stamp=stamp), self.assertRaises(ValueError):
raw_record_to_envelope(NOTE, "sha256:envelope-note", record_with(stamp))
if __name__ == "__main__":
unittest.main()

View file

@ -7,6 +7,7 @@ All notable changes to `@gamecult/cultnet-ts` are recorded here, following
### Breaking
- `CultNetDocumentRegistry.applyRawDocumentPutMessage` and `createRawSnapshotResponseForPut` throw `A raw put must carry a storedAt stamp.` when the put's `storedAt` is missing, empty or not a stamp (`isStoredAtStamp` of cultcache-ts), and nothing is stored. The parser no longer refuses a `cultnet.document_put_raw.v0` for its `storedAt`: the server and the node judge it, so a refusal can name `document` (snapshot records and database changes are still read strictly as non-empty).
- `decodeManifest` refuses a manifest with bytes after its first MessagePack value (`CDN artifact manifest has N bytes after its first MessagePack value.`), as the C# reference does; before, the tail was ignored. `contracts/cultmesh/content-acceptance-vectors.json` now holds `trailing_nil`, `trailing_reserved_code` and `trailing_truncated_array` as refused.
### Added

View file

@ -1289,7 +1289,9 @@ function validateDocumentPutRawMessage(input: unknown): asserts input is CultNet
throw new Error("Validation failed for cultnet.document_put_raw.v0: /document: is required");
}
validateRawDocumentRecord(candidate.document, "/document");
// A peer's put is judged for its stamp by the server and the node (isStoredAtStamp), which answer message_invalid naming
// `document` after the write authority has spoken; the parser must let such a put through to them.
validateRawDocumentRecord(candidate.document, "/document", { judgesStoredAt: false });
}
function validateSnapshotResponseRawMessage(
@ -1450,6 +1452,7 @@ function validateBinaryMessageRoot(
function validateRawDocumentRecord(
input: unknown,
instancePath: string,
{ judgesStoredAt = true }: { judgesStoredAt?: boolean } = {},
): asserts input is CultNetRawDocumentRecord {
if (!input || typeof input !== "object") {
throw new Error(`Validation failed for raw CultNet document record: ${instancePath}: must be an object`);
@ -1461,7 +1464,9 @@ function validateRawDocumentRecord(
requireOptionalNonEmptyString(candidate.schemaVersion, `${instancePath}/schemaVersion`);
requireOptionalNonEmptyString(candidate.schemaContentHash, `${instancePath}/schemaContentHash`);
requireNonEmptyString(candidate.recordKey, `${instancePath}/recordKey`);
requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`);
if (judgesStoredAt) {
requireNonEmptyString(candidate.storedAt, `${instancePath}/storedAt`);
}
if (candidate.payloadEncoding !== "messagepack") {
throw new Error(
`Validation failed for raw CultNet document record: ${instancePath}/payloadEncoding: must equal messagepack`,

View file

@ -6,6 +6,7 @@ import type {
} from "@gamecult/cultcache-ts";
import {
SchemaConflictError,
isStoredAtStamp,
decodeDocumentPayload,
documentPayloadBytes,
documentPayloadFromBytes,
@ -323,6 +324,10 @@ export class CultNetDocumentRegistry {
envelope: CultCacheEnvelope;
} {
const binding = this.#requireSchemaBinding(message.document.schemaId);
// A put is stored under the stamp it carries; one that is no stamp is refused, and nothing is minted for it.
if (!isStoredAtStamp(message.document.storedAt)) {
throw new Error("A raw put must carry a storedAt stamp.");
}
return {
binding,
envelope: {

View file

@ -0,0 +1,53 @@
// A peer's raw put is stored under the stamp it carries; one that is no stamp is refused and nothing is minted or stored
// (variants:ruling:raw-put-without-stamp-refused). The registry is the node-level owner: every row of the shared vector.
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import test from "node:test";
import { decode } from "@msgpack/msgpack";
import { z } from "zod";
import { CultCache, defineDocumentType, encodeDocumentPayload } from "@gamecult/cultcache-ts";
import { CultNetDocumentRegistry, defineCultNetDocumentBinding, parseCultNetMessage, type CultNetMessage } from "../src";
const VECTOR = join(__dirname, "..", "..", "..", "..", "tests", "vectors", "stored-at-stamp", "stamps.msgpack");
const note = defineDocumentType({
type: "tests.stamped_note",
schemaId: "tests.stamped_note.v0",
schema: z.object({ noteId: z.string(), text: z.string() }),
name: "noteId",
});
const registry = new CultNetDocumentRegistry([defineCultNetDocumentBinding({ definition: note })]);
function putOf(key: string, storedAt: unknown): CultNetMessage {
const payload = encodeDocumentPayload(note, { noteId: key, text: "peer" });
const document: Record<string, unknown> = { schemaId: note.schemaId, recordKey: key, payloadEncoding: "messagepack", payload };
if (storedAt !== undefined) document.storedAt = storedAt;
return { schemaVersion: "cultnet.document_put_raw.v0", messageId: `put-${key}`, document } as unknown as CultNetMessage;
}
test("the registry stores a raw put only under a stamp, for every row of the vector and for a missing or mistyped one", async () => {
const rows = decode(readFileSync(VECTOR)) as [string, boolean][];
const cases: [string, unknown, boolean][] = [...rows.map(([text, parses]): [string, unknown, boolean] => [JSON.stringify(text), text, parses]),
["missing", undefined, false], ["number", 1_700_000_000_000, false], ["null", null, false]];
const cache = CultCache.builder().withDocumentType(note).build();
for (const [label, storedAt, parses] of cases) {
const message = putOf("k", storedAt);
// The parser lets a put through to its judges: the server and the registry refuse it, naming the document.
parseCultNetMessage(message, "cultnet.schema.v0");
const before = cache.getEnvelope(note, "k");
if (parses) {
await registry.applyRawDocumentPutMessage(cache, message as never);
assert.equal(cache.getRequiredEnvelope(note, "k").storedAt.length > 0, true, label);
} else {
await assert.rejects(registry.applyRawDocumentPutMessage(cache, message as never), /storedAt stamp/, label);
assert.deepEqual(cache.getEnvelope(note, "k"), before, `${label}: nothing minted or stored`);
}
}
// The refused puts left the store writable.
await registry.applyRawDocumentPutMessage(cache, putOf("after", "2026-11-07T00:00:00Z") as never);
assert.equal(cache.getRequired(note, "after").text, "peer");
});
test("a refused put does not serve either: createRawSnapshotResponseForPut refuses it too", () => {
assert.throws(() => registry.createRawSnapshotResponseForPut(putOf("k", "yesterday") as never, "m"), /storedAt stamp/);
});

View file

@ -1055,6 +1055,13 @@ namespace GameCult.Networking
var key = new CultRecordKey(message.Document.RecordKey);
var descriptor = _documents.ResolveDescriptorForRawDocument(message.Document);
authorize?.Invoke(descriptor.SchemaId, key);
// A peer's put is judged for its stamp after its authority and before any shard is asked (so it is never forwarded):
// the cache would mint a stamp for one that is no stamp, and a refused put stores and mints nothing.
if (authorize != null && !CultNetStoredAtStamp.IsStamp(message.Document.StoredAt))
{
throw new CultNetStoredAtStampException();
}
var shard = ResolveShardInternal(descriptor, key);
EnsurePrimary(shard, descriptor.SchemaId, key, message.ShardEpoch);
var document = _documents.DeserializeRawDocument(message.Document);

View file

@ -475,6 +475,11 @@ namespace GameCult.Networking
_server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}");
Refuse(peer, write, CultNetErrorMessage.ForRemoteWriteRefused(ex.Request, ex.Reason));
}
catch (CultNetStoredAtStampException ex)
{
_server.Logger.LogWarning($"CultNet raw put refused: {ex.Message}");
Refuse(peer, write, CultNetErrorMessage.ForMessageInvalid("document", "document.storedAt is not a stamp"));
}
catch (CultNetShardAuthorityException ex)
{
bool forwarded;

View file

@ -0,0 +1,63 @@
#nullable enable
using System;
namespace GameCult.Networking
{
// The one shape a peer's storedAt must have: RFC 3339 date-time, YYYY-MM-DDThh:mm:ss, an optional fraction of 1 to 9 digits,
// then Z or +hh:mm / -hh:mm. Uppercase T and Z, ASCII digits, no whitespace, a real calendar date, seconds 00-59, years
// 0001-9999 for both the local date and the UTC instant, offset hours 00-23 and minutes 00-59. Checked by shape and range, never by DateTimeOffset.TryParse, which is
// culture-aware and takes far more than this (tests/vectors/stored-at-stamp, variants:ruling:raw-put-without-stamp-refused).
internal static class CultNetStoredAtStamp
{
public static bool IsStamp(string? text)
{
if (text == null || text.Length < 20) return false;
if (!Digits(text, 0, 4, out var year) || text[4] != '-' || !Digits(text, 5, 2, out var month) || text[7] != '-'
|| !Digits(text, 8, 2, out var day) || text[10] != 'T' || !Digits(text, 11, 2, out var hour) || text[13] != ':'
|| !Digits(text, 14, 2, out var minute) || text[16] != ':' || !Digits(text, 17, 2, out var second))
return false;
if (year < 1 || month is < 1 or > 12 || day < 1 || day > DateTime.DaysInMonth(year, month)
|| hour > 23 || minute > 59 || second > 59)
return false;
var i = 19;
if (text[i] == '.')
{
var start = ++i;
while (i < text.Length && IsDigit(text[i])) i++;
if (i - start is < 1 or > 9) return false;
}
if (i >= text.Length) return false;
if (text[i] == 'Z') return i == text.Length - 1;
if (!(text.Length - i == 6 && (text[i] == '+' || text[i] == '-')
&& Digits(text, i + 1, 2, out var offsetHour) && text[i + 3] == ':' && Digits(text, i + 4, 2, out var offsetMinute)
&& offsetHour <= 23 && offsetMinute <= 59))
return false;
// The UTC instant must lie in years 0001-9999 too: local minus offset may not leave DateTime's range.
var offsetTicks = (offsetHour * 60L + offsetMinute) * TimeSpan.TicksPerMinute * (text[i] == '-' ? -1 : 1);
var utcTicks = new DateTime(year, month, day, hour, minute, second).Ticks - offsetTicks;
return utcTicks >= DateTime.MinValue.Ticks && utcTicks <= DateTime.MaxValue.Ticks;
}
private static bool IsDigit(char c) => c is >= '0' and <= '9';
private static bool Digits(string text, int start, int count, out int value)
{
value = 0;
for (var i = start; i < start + count; i++)
{
if (i >= text.Length || !IsDigit(text[i])) return false;
value = value * 10 + (text[i] - '0');
}
return true;
}
}
// A peer's raw put whose storedAt is no stamp: refused message_invalid, field document, nothing stored or minted.
internal sealed class CultNetStoredAtStampException : Exception
{
public CultNetStoredAtStampException() : base("a raw put document must carry a storedAt stamp") { }
}
}

View file

@ -1119,6 +1119,8 @@ namespace GameCult.Networking.Tests
foreach (var field in BlankRuleFields)
foreach (var times in new[] { 1, 3 })
{
// A storedAt must be a stamp: a non-blank one that is no stamp is not "a name" (write-receipt.md, Required fields).
if (!blank && field == "storedAt") continue;
var text = string.Concat(Enumerable.Repeat(char.ConvertFromUtf32(Convert.ToInt32(hex, 16)), times));
var rig = Build();
var wire = new Wire();

View file

@ -405,6 +405,114 @@ namespace GameCult.Networking.Tests
Assert.That(rig.Logger.Errors, Is.Empty, "a null decision is a refusal, not a fault");
}
// ---- a peer's storedAt must be a stamp (tests/vectors/stored-at-stamp, variants:ruling:raw-put-without-stamp-refused) ----
private static (string Text, bool Parses)[] StampVectors()
{
var dir = new System.IO.DirectoryInfo(TestContext.CurrentContext.TestDirectory);
while (dir != null && !System.IO.Directory.Exists(System.IO.Path.Combine(dir.FullName, "tests", "vectors"))) dir = dir.Parent;
var bytes = System.IO.File.ReadAllBytes(System.IO.Path.Combine(dir!.FullName, "tests", "vectors", "stored-at-stamp", "stamps.msgpack"));
return MessagePack.MessagePackSerializer.Deserialize<object[]>(bytes)
.Select(row => (Row: (object[])row, 0)).Select(r => ((string)r.Row[0], (bool)r.Row[1])).ToArray();
}
// A blank stamp is a frame that does not read (write-receipt.md, vector blankFields: field "message"); any other text
// that is no stamp is message_invalid on field "document". Both are refused before the store is asked.
private static bool IsBlankText(string text) => text.All(c => char.IsWhiteSpace(c));
[Test]
public async Task EveryStampVectorRowIsJudgedThroughTheRealServerHandler()
{
using var wire = new WirePair();
var rig = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()), peer: wire.Peer);
var rows = StampVectors();
Assert.That(rows, Has.Length.GreaterThan(30));
var n = 0;
foreach (var (text, parses) in rows)
{
var key = new CultRecordKey($"tests:stamp:{n++}");
var put = rig.PutOf(key, "peer");
put.Document.StoredAt = text;
var publishedBefore = rig.Published.Count;
var logBefore = rig.LogLength;
await rig.Put(put);
if (parses)
{
Assert.That(rig.TextOf(key), Is.EqualTo("peer"), $"stamp '{text}' is admitted");
Assert.That(wire.Errors, Is.Empty, $"stamp '{text}' is not answered with an error");
continue;
}
var reply = await wire.NextErrorAsync();
Assert.That(reply.Code, Is.EqualTo("message_invalid"), $"'{text}'");
Assert.That(reply.Details!.Field, Is.EqualTo(IsBlankText(text) ? "message" : "document"), $"'{text}'");
Assert.That(rig.TextOf(key), Is.Null, $"'{text}' stored nothing");
Assert.That(rig.Cache.Get<NetworkSchemaNote>(key), Is.Null, $"'{text}' minted nothing");
Assert.That(rig.Published, Has.Count.EqualTo(publishedBefore), $"'{text}' published nothing");
Assert.That(rig.LogLength, Is.EqualTo(logBefore), $"'{text}' logged nothing");
Assert.That(wire.Errors, Is.Empty, "one reply per refused write");
}
var fresh = new CultRecordKey("tests:stamp:after");
await rig.Put(rig.PutOf(fresh, "after"));
Assert.That(rig.TextOf(fresh), Is.EqualTo("after"), "the store stays writable after the refusals");
Assert.That(wire.Errors, Is.Empty);
}
// The host's own door (the public ApplyPutAsync overload, no authorize) is exempt from the stamp judgment: the cache mints
// a stamp for it. Applying the check to every call would refuse this put.
[Test]
public async Task TheHostsOwnDoorIsExemptFromTheStampJudgment()
{
var rig = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Refuse("no")));
var put = rig.PutOf(KeyA, "host");
put.Document.StoredAt = "yesterday";
await rig.Database.ApplyPutAsync(put);
Assert.That(rig.TextOf(KeyA), Is.EqualTo("host"), "the host's put with no stamp is applied, not refused");
}
[Test]
public async Task AnUngrantedPeerIsToldItIsNotAuthorizedBeforeItsStampIsJudgedAndAnAliasAsksTheCanonicalId()
{
using var wire = new WirePair();
var authority = new RecordingAuthority(_ => CultNetRemoteWriteDecision.Refuse("no"));
var rig = Build(authority, peer: wire.Peer);
var put = rig.PutOf(KeyA, "peer");
put.Document.StoredAt = "yesterday";
put.Document.SchemaId = "runtime.generated.network-note.ui.42";
await rig.Put(put);
var reply = await wire.NextErrorAsync();
Assert.That(reply.Code, Is.EqualTo("write_not_authorized"), "authority first, the stamp is not judged");
Assert.That(authority.Requests.Single().SchemaId, Is.EqualTo(rig.SchemaId));
Assert.That(rig.TextOf(KeyA), Is.Null);
}
[Test]
public async Task AGarbageStampIsNeverForwardedToThePrimary()
{
using var wire = new WirePair();
var forwarder = new CountingForwarder();
var rig = Build(new RecordingAuthority(_ => CultNetRemoteWriteDecision.Grant()), primary: false, forwarder, wire.Peer);
var put = rig.PutOf(KeyA, "peer");
put.Document.StoredAt = "2026-06-25T00:00:00";
await rig.Put(put);
var reply = await wire.NextErrorAsync();
Assert.That(reply.Code, Is.EqualTo("message_invalid"));
Assert.That(reply.Details!.Field, Is.EqualTo("document"));
Assert.That(forwarder.Puts, Is.EqualTo(0));
await rig.Put(rig.PutOf(KeyA, "peer"));
Assert.That(forwarder.Puts, Is.EqualTo(1), "a stamped put still forwards");
}
private sealed class RecordingAuthority : ICultNetRemoteWriteAuthority
{
private readonly Func<CultNetRemoteWriteRequest, CultNetRemoteWriteDecision> _decide;

View file

@ -0,0 +1,11 @@
# stored-at-stamp vectors
`stamps.msgpack`: an array of `[text, parses]`. `text` (str) is a `storedAt` a peer might send; `parses` is whether it is a stamp.
Every runtime that judges a peer's `storedAt` (the raw put of cultmesh-py, and the C#, TypeScript and Rust servers) is held to
these answers, and a text that does not parse is refused `message_invalid` (field `document`) with nothing minted or stored
(`variants:ruling:raw-put-without-stamp-refused`).
A stamp is an RFC 3339 date-time: `YYYY-MM-DDThh:mm:ss`, an optional fraction of 1 to 9 digits, and `Z` or a numeric offset
`+hh:mm` / `-hh:mm` (hours 00-23, minutes 00-59). Uppercase `T` and `Z`, ASCII digits, no surrounding whitespace, a real
calendar date, seconds 00-59 (no leap second). The local year must be 1-9999 AND the UTC instant (after the offset is applied) must lie in years 0001-9999. The reference is `parse_stored_at` in
`packages/cultcache-py/src/cultcache_py/stores.py` (#172); this file was written from it and is a reviewed commit when it changes.

Binary file not shown.