You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Stack.create() generates the record ID client-side (packages/core/src/stack.ts:389) and APIAdapter.createRecord() POSTs the full record — ID included — to the server (packages/adapter-api/src/index.ts:304–307). Nothing in the spec or the wire contract says the server validates, constrains, or may reassign that ID. #48 made version and updatedAt server-authoritative on exactly this reasoning (clients can't be trusted to maintain invariants they can't see); id was left behind as a fully client-authoritative field.
Under the #45 topology — multiple apps, multiple processes, one server — that trust breaks three ways:
1. Cross-client collisions
The ID format is a 9-char timestamp prefix + 3 random chars (32,768 values). Same-millisecond monotonicity is enforced by module-level state (lastNowId/lastRandChars, packages/core/src/id.ts:22–23) — which is per-process. Two apps writing through the server in the same millisecond draw independently from 32k values; so do two tabs of the same browser app. Birthday math says ~1% collision odds at just 26 same-ms writers, and the failure mode is whatever the adapter's INSERT happens to do — the spec never defines duplicate-create semantics (silent replace? constraint error? which status?).
2. Malicious or malformed IDs
The server accepts any string as an ID. Nothing rejects:
Reserved-looking IDs — system records are addressed by well-known IDs (_config); the query-layer convention that hides system records is per-adapter SQL (backlog: system-record hardening). A client that creates a record with an underscore-prefixed or system-colliding ID is stepping into namespace that conventions elsewhere assume is library-controlled.
Non-Crockford / oversized strings — IDs flow into URLs (/records/:id), cursor codecs, and sort comparisons that assume the time-sortable 12-char format. An ID of 10 KB of unicode is a valid create today.
Forged timestamps — the ID prefix is the creation-time sort key. A client can mint IDs that sort anywhere in history, which quietly breaks "lexicographic order matches creation order" for everyone else reading the stack.
3. Mechanical bugs in the generator (apply even single-process)
% max off-by-one (packages/core/src/id.ts:94–102): max = 32³ − 1 = 32767, and the suffix is value % max, which yields 0–32766 — zzz is unreachable, and residue 0 absorbs the missing mass (very slightly biased). Should be modulo 32768 (BASE ** RAND_SUFFIX_LENGTH), with the rejection-sampling threshold computed for that modulus. Cosmetic-scale bias, but the rejection loop exists specifically to avoid bias, and it also shrinks the same-ms space by one for no reason.
Clock regression silently breaks sortability (id.ts:145–153): if the wall clock steps backward, nowId !== lastNowId is true, so a fresh random suffix is drawn and the new ID sorts before records already written. No error, no clamp. Fix: track the last timestamp and clamp to max(Date.now(), lastTimestamp) so IDs stay monotone within a process even across clock steps (NTP corrections, suspend/resume).
Proposed direction
Server validates always; server assignment stays available as a policy. Client-side generation is worth keeping — it's what makes adapter-local and offline-shaped flows work, and create() returning a complete record cheaply is good API. The gap is that the server currently audits nothing:
Spec: server-side ID validation on POST /records. Charset (Crockford base-32, lowercase), exact length (12), reserved-prefix rejection (_* unless the write is the library's own system-record path). Violations → 400 (malformed, per the spec's 400/422 split).
Timestamp-prefix sanity (server option). A server MAY reject IDs whose timestamp prefix is implausibly far from server time (clock-skew tolerance, e.g. hours not years). Optional because legitimate offline creation produces stale-but-honest prefixes; the spec should name the trade-off rather than pick for everyone.
Fix the two generator bugs (modulus, clock clamp) in packages/core/src/id.ts — independent of everything above and purely local.
Full server assignment (client POSTs without an ID, server mints) is deliberately not required: it buys little once validation + 409 exist, and it breaks ID-referencing patterns where the client needs the ID before the create round-trips. Worth leaving open as a server capability, not a mandate.
Problem
Stack.create()generates the record ID client-side (packages/core/src/stack.ts:389) andAPIAdapter.createRecord()POSTs the full record — ID included — to the server (packages/adapter-api/src/index.ts:304–307). Nothing in the spec or the wire contract says the server validates, constrains, or may reassign that ID. #48 madeversionandupdatedAtserver-authoritative on exactly this reasoning (clients can't be trusted to maintain invariants they can't see);idwas left behind as a fully client-authoritative field.Under the #45 topology — multiple apps, multiple processes, one server — that trust breaks three ways:
1. Cross-client collisions
The ID format is a 9-char timestamp prefix + 3 random chars (32,768 values). Same-millisecond monotonicity is enforced by module-level state (
lastNowId/lastRandChars,packages/core/src/id.ts:22–23) — which is per-process. Two apps writing through the server in the same millisecond draw independently from 32k values; so do two tabs of the same browser app. Birthday math says ~1% collision odds at just 26 same-ms writers, and the failure mode is whatever the adapter'sINSERThappens to do — the spec never defines duplicate-create semantics (silent replace? constraint error? which status?).2. Malicious or malformed IDs
The server accepts any string as an ID. Nothing rejects:
_config); the query-layer convention that hides system records is per-adapter SQL (backlog: system-record hardening). A client that creates a record with an underscore-prefixed or system-colliding ID is stepping into namespace that conventions elsewhere assume is library-controlled./records/:id), cursor codecs, and sort comparisons that assume the time-sortable 12-char format. An ID of 10 KB of unicode is a valid create today.3. Mechanical bugs in the generator (apply even single-process)
% maxoff-by-one (packages/core/src/id.ts:94–102):max = 32³ − 1 = 32767, and the suffix isvalue % max, which yields 0–32766 —zzzis unreachable, and residue 0 absorbs the missing mass (very slightly biased). Should be modulo 32768 (BASE ** RAND_SUFFIX_LENGTH), with the rejection-sampling threshold computed for that modulus. Cosmetic-scale bias, but the rejection loop exists specifically to avoid bias, and it also shrinks the same-ms space by one for no reason.id.ts:145–153): if the wall clock steps backward,nowId !== lastNowIdis true, so a fresh random suffix is drawn and the new ID sorts before records already written. No error, no clamp. Fix: track the last timestamp and clamp tomax(Date.now(), lastTimestamp)so IDs stay monotone within a process even across clock steps (NTP corrections, suspend/resume).Proposed direction
Server validates always; server assignment stays available as a policy. Client-side generation is worth keeping — it's what makes
adapter-localand offline-shaped flows work, andcreate()returning a complete record cheaply is good API. The gap is that the server currently audits nothing:POST /records. Charset (Crockford base-32, lowercase), exact length (12), reserved-prefix rejection (_*unless the write is the library's own system-record path). Violations → 400 (malformed, per the spec's 400/422 split).conflicterror code; never silent replace. Client recovery is trivial (regenerate, retry once) and worth stating in the spec.packages/core/src/id.ts— independent of everything above and purely local.Full server assignment (client POSTs without an ID, server mints) is deliberately not required: it buys little once validation + 409 exist, and it breaks ID-referencing patterns where the client needs the ID before the create round-trips. Worth leaving open as a server capability, not a mandate.
Open questions
createdAtbecome server-authoritative in the same pass (it's the same trust argument as Clarify whatversionis for, and add opt-in optimistic concurrency so concurrent writers can't corrupt version history #48'supdatedAt, and it's redundant with the ID's timestamp prefix — today they can disagree)?create()(regenerate suffix, single retry) — automatic or app-visible?Refs #48 (server-authoritative field precedent), #45 (topology), #51 (client-supplied
appIddocumented untrusted — same posture), #52 (fixtures), #53 (409 error code round-trip).