Skip to content

ID authority under server topology: clients mint record IDs and the server trusts them #55

Description

@cuibonobo

Problem

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:

  1. 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).
  2. Spec: duplicate-create semantics. Existing ID → 409 with the APIAdapter collapses the error taxonomy: Stack* errors never survive the wire round trip #53 conflict error code; never silent replace. Client recovery is trivial (regenerate, retry once) and worth stating in the spec.
  3. 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.
  4. Fix the two generator bugs (modulus, clock clamp) in packages/core/src/id.ts — independent of everything above and purely local.
  5. Conformance fixtures (PATCH contract mismatch: spec defines content-only merge patch, APIAdapter sends record-field envelopes #52) cover: invalid charset → 400, reserved prefix → 400, duplicate → 409.

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

Refs #48 (server-authoritative field precedent), #45 (topology), #51 (client-supplied appId documented untrusted — same posture), #52 (fixtures), #53 (409 error code round-trip).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions