// Cursor provenance: the type-level machinery that makes "never adopt an // `Email/get` state as an `Email/changes` cursor" a compile error rather than a // code-review convention. // // This exact bug shipped on the mobile client (its defect D4) and silently // corrupted sync: `getEmailChanges` returned `null` for ANY error, so a // transient 503 on `Email/changes` caused an `Email/get` state captured in the // same cycle to be adopted as the next cursor - fast-forwarding the cursor over // every change the client had not seen, with no resync. The cost is invisible: // the store looks healthy and is permanently missing mail. // // Two brands, and an ORDERING rule rather than a source rule. "Only a // `Foo/changes.newState` may ever be a cursor" is tempting but FALSE - bootstrap // and reconcile legitimately seed from `Foo/get {ids: []}`'s `state`, which // RFC 8620 s5.1 explicitly permits. A rule the design itself has to violate is a // rule that gets bypassed at the one call site that matters, so the rule is: // // A cursor ADVANCES to a ChangesState from the same (jmapAccountId, type). // It may be SEEDED from a SnapshotState only inside an EnumerationCommitment // whose enumeration starts after that snapshot. Nothing else, from anywhere, // ever becomes a cursor. /** Types we hold a `/changes` cursor for. NOT a list of push types. */ export type CursorType = 'Email' | 'Mailbox'; export const CURSOR_TYPES: readonly CursorType[] = ['Email', 'Mailbox']; /** From a `Foo/changes` response's `newState`. The only value the delta path may advance to. */ export type ChangesState = string & { readonly __brand: 'ChangesState' }; /** From a `Foo/get` response's `state`. A valid cursor ONLY under the ordering rule above. */ export type SnapshotState = string & { readonly __brand: 'SnapshotState' }; /** * A JMAP body is parsed JSON, so without a runtime check a `null`, a number or * an object could be laundered through a cast into something the engine treats * as a cursor forever. The brand certifies PROVENANCE; this certifies SHAPE. */ function certifyStateToken(value: unknown, kind: string): string { if (typeof value !== 'string' || value.length === 0) { throw new TypeError( `${kind}: expected a non-empty string state token, got ` + `${value === null ? 'null' : typeof value}`, ); } return value; } /** * Mint a `ChangesState`. Callable ONLY from the `Foo/changes` response parser in * `./jmap.ts` - that is the entire point of the brand. There is a test asserting * no other module casts to these types. */ export function asChangesState(newState: unknown): ChangesState { return certifyStateToken(newState, 'asChangesState') as ChangesState; } /** Mint a `SnapshotState`. Callable ONLY from the `Foo/get` response parser in `./jmap.ts`. */ export function asSnapshotState(state: unknown): SnapshotState { return certifyStateToken(state, 'asSnapshotState') as SnapshotState; } /** * The tag is a REAL, module-private `Symbol()`, deliberately not exported and * deliberately not `declare const ... : unique symbol`. * * - Unexported means no object literal in any other module can produce this * type, so `mintEnumerationCommitment` is the only constructor. Declaring the * interface without a symbol tag would let any module falsify it with a * literal, making the seed path's teeth strictly weaker than * `advanceCursor`'s - which is the path that needs them most. * - `declare const x: unique symbol` is TYPE-LEVEL ONLY and emits no runtime * value, so using it as a computed key throws * `ReferenceError: x is not defined` the first time the mint runs. That * mistake is in the superseded design document; it cost the mobile port a * build failure. A `Symbol()` assigned to a `const` still infers * `unique symbol`, so unforgeability is identical and no cast is needed. */ const enumerationCommitmentTag = Symbol('EnumerationCommitment'); /** * A durable promise to enumerate. Holding one is what entitles a caller to seed * a cursor from a snapshot state: the snapshot is only a safe cursor because an * enumeration that starts AFTER it is committed to run. */ export interface EnumerationCommitment { readonly [enumerationCommitmentTag]: true; readonly jmapAccountId: string; readonly snapshot: SnapshotState; /** The retention floor the enumeration is working toward. */ readonly targetFrom: string; /** * The floor PINNED for this enumeration. Equal to `targetFrom` for a * bootstrap; for a reconcile it is the floor captured at step 0, and the sweep * deletes only against THIS value, never a `targetFrom` that moved while the * reconcile was running. * * Without the pin: widening retention mid-reconcile (very plausible - the * reconcile banner is exactly what prompts someone to go change the setting) * makes the sweep delete against the new wide window while the enumeration * only covered the old narrow one. Everything in the gap is deleted * permanently, because `coveredFrom` is then set to the wider floor and * `/changes` cannot re-deliver pre-existing mail. */ readonly sweepFloor: string; readonly kind: 'bootstrap' | 'reconcile'; } export function mintEnumerationCommitment(args: { jmapAccountId: string; snapshot: SnapshotState; targetFrom: string; sweepFloor: string; kind: 'bootstrap' | 'reconcile'; }): EnumerationCommitment { return { [enumerationCommitmentTag]: true, jmapAccountId: args.jmapAccountId, snapshot: args.snapshot, targetFrom: args.targetFrom, sweepFloor: args.sweepFloor, kind: args.kind, }; } export function coveragePhaseForCommitment( commitment: EnumerationCommitment, ): 'scanning' | 'reconciling' { return commitment.kind === 'bootstrap' ? 'scanning' : 'reconciling'; }