/**
 * SyncService — the React client for `mould-detect-sync-lambda` (slice 6).
 *
 * One sync backend serves both clients (MOULD-ASSESSMENT-MIGRATION-BRIEF.md §8
 * decision 3, ADR-009 slice-6 note). The Lambda never touches file bytes: it
 * verifies the Clerk session JWT, forces every key under `users/{sub}/`, and
 * mints short-lived presigned S3 URLs. This file mirrors the Swift client
 * (`Mould-Detect-Swift-App/MouldDetect/Data/Sync/CloudSyncService.swift`) —
 * same flow (presign → PUT / list → presign GET → download), same file names.
 *
 * Wire contract: `mould-detect-sync-lambda/CLIENT-CONTRACT.md`. Read it before
 * changing anything here; where this file and that file disagree, the Lambda
 * wins.
 *
 *   POST /presign  { method: "PUT"|"GET", files: [{ path, contentType? }] }
 *                  → { urls: [{ path, url }] }   (order preserved, max 32)
 *   GET  /list     → { scans: [...], items: [{ namespace, id, files: [...] }] }
 *
 * Key scheme — three segments, `users/{sub}/` prepended server-side:
 *
 *   scans/<scanId>/metadata.json          the scan record, without `thumbnail`
 *   scans/<scanId>/thumbnail.jpg          the card thumbnail, decoded from its data URL
 *   properties/<propertyId>/record.json
 *   assessments/<assessmentId>/record.json
 *   profile/current/record.json
 *   branding/current/record.json          WITHOUT `logoDataUrl`, with `hasLogo` (1.0.4) — see below
 *   branding/current/logo.png             only when a logo is stored; removing the
 *                                         logo queues its POST /delete (1.0.4, Q-13)
 *   component-photos/<photoId>/metadata.json   owner ids + createdAt + exif (envelope)
 *   component-photos/<photoId>/image.jpg       the stored ≤1600 px JPEG, raw bytes
 *
 * Component photos (2026-10-05) are immutable per id, so their skip-unchanged
 * stamp covers the metadata only and the image is read from IndexedDB just
 * before it is uploaded, one photo at a time (memory stays one photo deep).
 * A PUT in this namespace declares `contentLength`, which the Lambda signs, and
 * is refused over 2 MiB. Deleting a photo queues `POST /delete` for both
 * objects in `mould-detect-sync-state.deletes`; it is tried at once and again
 * at the start of every backup pass until it lands. The assessment carries a
 * tombstone (`deletedPhotos[]`), and restore never downloads a tombstoned id —
 * it queues the server delete instead.
 *
 * Every JSON object is a versioned ENVELOPE, never a bare record:
 *
 *   { backupSchema: 1, kind: 'scan'|'property'|'assessment'|'profile'|'branding',
 *     rev: 3, updatedAt: '2026-09-21T02:00:00.000Z', record: { … } }
 *
 * `kind` makes an object self-describing after an export; `updatedAt` lets a
 * reader order two copies without knowing each namespace's stamp field; `rev`
 * counts observed local changes to that record. Restore accepts an envelope OR
 * a bare record, so objects written by the slice-6 client still read.
 *
 * Contracts this file keeps:
 *
 *   • **Never throws to the caller.** `backup()` and `restore()` always resolve
 *     a JSON-primitive result `{ ok, uploaded, restored, skipped, errors[],
 *     reason }`. A caller never needs a try/catch, exactly as the caches never
 *     reject (CLAUDE.md, the data-moat rule).
 *   • **Display is gated, capture is not.** Nothing here gates what is CAPTURED
 *     — every scan still gets its snapshots. `useCloudSync()` gates the *UI*
 *     (Professional+, brief §4), and an empty `syncEndpoint` makes the whole
 *     feature invisible and inert.
 *   • **Signed out is a friendly stop, not an error.** `getToken()` resolving
 *     null aborts before any request is made.
 *   • **Restore & conflict rules, contract 1.0** (CLIENT-CONTRACT.md R1–R10,
 *     ADR-014). A remote record replaces a local one only when its stamp is
 *     STRICTLY newer (`syRemoteWins`); a scan never edited since capture is
 *     therefore added-if-missing — the Swift restore's rule. Assessments merge
 *     field by field (`asMergeAssessment`). A backup READS a changed record's
 *     cloud copy before it overwrites it (`_reconcile`). Tombstones
 *     (`deletedPhotos`, `deletedItems`, `<ns>/<id>/deleted.json`) beat every
 *     copy, and an object written by a newer app version is never imported or
 *     overwritten (`syReadObject`).
 *   • **Restore goes through the stores, not around them.** Each provider
 *     registers an importer (`registerImporter(namespace, fn)`) that merges into
 *     LIVE state and lets the ordinary debounce persist. Slice 6 wrote to
 *     IndexedDB and then suspended every writer for the session; a suspended
 *     writer resolved as success while persisting nothing, which lost whatever
 *     the user typed next (review F1). A namespace with no importer registered
 *     is skipped and named in `errors[]` — never written behind a store's back.
 *   • **Unchanged objects are not re-uploaded.** A SHA-256 of each record (plus
 *     its images) is kept per namespace+id in `mould-detect-sync-state.stamps`,
 *     exactly as the Swift client's `contentStamp` does; a matching stamp means
 *     the object is skipped and counted as `unchanged`.
 *   • **The receipt belongs to ONE account** (code review Q-01 F1). It records
 *     `accountSub`; before any pass, delete or restore runs, a different
 *     signed-in `sub` (a shared device's sign-in switch, the Clerk → Cognito
 *     cutover) resets stamps, revs, retries, the delete queue and the last
 *     backup/restore times (`syAccountPatch`), so the new account's first
 *     backup uploads everything and never runs the old account's deletes.
 *   • **One pass at a time, and a failed object never blocks the batch.**
 *     `backup()`/`restore()` are single-flight: a second call while a pass of
 *     the same kind is running resolves with THAT pass. A file that fails is
 *     isolated to its own item and given a jittered `retryAt`, so the next run
 *     spends its round trips on everything else first.
 *
 * File-local helpers are `sy`-prefixed: there is no module system here, every
 * top-level `var` is a window global (CLAUDE.md hard rule).
 */

/* eslint-disable no-var */

var SY_CONFIG_URL   = 'components/SyncService/sync-config.json';
/** Count-only sync OUTCOME events (Cloud Saves dashboard). The same bytes as
 *  mould-detect-sync-lambda/src/sync-event-schema.json — a Lambda test asserts it. */
var SY_HEALTH_SCHEMA_URL = 'components/SyncService/sync-event-schema.json';
var SY_HEALTH_QUEUE_KEY  = 'mould-detect-sync-health-queue';   // localStorage, ≤ SY_HEALTH_MAX events
var SY_HEALTH_MAX        = 50;                                  // the Lambda's MAX_BATCH
var SY_MAX_FILES    = 32;                 // MAX_FILES in the Lambda; a bigger batch is a 400.
var SY_STATE_VERSION = 2;                 // v2 adds properties to `counts`, plus stamps/revs/retries.

/** The Lambda's own `PATH_RE`, verbatim. Validated here so a bad id is skipped
 *  with a named error instead of failing the whole all-or-nothing batch. */
var SY_PATH_RE = /^(scans|properties|assessments|profile|branding|component-photos)\/[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;

/** The six namespaces, in backup order. Ordering matters for restore: a
 *  property must exist before the scans and assessments that reference it, and
 *  component photos are fetched after the assessments that name them. */
var SY_NAMESPACES = ['scans', 'properties', 'assessments', 'profile', 'branding', 'component-photos'];

/** Component photos (2026-10-05) — CLIENT-CONTRACT.md "Component photos". */
var SY_PHOTO_NS = 'component-photos';
var SY_PHOTO_MAX_BYTES = 2 * 1024 * 1024;        // the Lambda's MAX_COMPONENT_PHOTO_BYTES
var SY_PHOTO_META_MAX_BYTES = 64 * 1024;         // the Lambda's metadata.json ceiling
var SY_PHOTOS_CHANGED_EVENT = 'mould-detect-component-photos-changed';  // AssessmentStore listens
var SY_DELETE_IDS_PER_CALL = 16;                 // two objects each → the Lambda's 32-path cap

/** The envelope (A2). `backupSchema` is the wire version of the WRAPPER, kept
 *  independent of each record's own `v` — the Swift side's convergence point is
 *  `ScanBackupRecord.schemaVersion`, which means the same thing for its payload. */
var SY_BACKUP_SCHEMA = 1;
var SY_KINDS = {
    scans: 'scan', properties: 'property', assessments: 'assessment',
    profile: 'profile', branding: 'branding', 'component-photos': 'component-photo'
};

/** The client contract version these rules implement (CLIENT-CONTRACT.md,
 *  frozen 2026-10-05). Bumped only under that file's change policy. */
var SY_CONTRACT_VERSION = '1.0';

/** The highest record `v` this build reads, per namespace (R6). A record above
 *  it was written by a newer client: never imported, never overwritten.
 *  Scans and properties carry no `v`. */
var SY_RECORD_VERSIONS = { assessments: 1, profile: 1, branding: 1, 'component-photos': 1 };

/** Record tombstones (R5): `<namespace>/<id>/deleted.json`. Its presence beats
 *  every copy of the record, on every device. Written for the namespaces whose
 *  records can be deleted in the app; honoured for any record namespace. */
var SY_TOMBSTONE_FILE = 'deleted.json';
var SY_TOMBSTONE_WRITERS = ['scans', 'properties'];
var SY_TOMBSTONE_READERS = ['scans', 'properties', 'assessments'];

/** Record purges (contract 1.0.2, 2026-10-13, Q-09): once a scan's tombstone
 *  has landed, `POST /delete { records }` removes every other object of that
 *  scan (record, thumbnail, Swift's original/heatmap) — the tombstone stays.
 *  Before 1.0.2 a deleted scan's photo and record stayed in S3 forever.
 *  Properties since 1.0.3 (Q-12): a deleted property's record.json holds its
 *  address. The Lambda's PURGEABLE_NAMESPACES / MAX_PURGE_RECORDS. */
var SY_PURGE_NAMESPACES = ['scans', 'properties'];
var SY_PURGE_IDS_PER_CALL = 16;

/** Single-file deletes (contract 1.0.4, 2026-10-13, Q-13): delete-queue key →
 *  the exact object paths `POST /delete { files }` removes, and the record the
 *  file belongs to. Removing a branding logo used to leave `logo.png` in S3
 *  for ever. The Lambda's DELETABLE_FILES lists the same paths. The key is
 *  deliberately NOT `branding/current`: restores skip a record with a pending
 *  delete, and the branding record itself is never deleted. */
var SY_FILE_DELETES = {
    'branding/logo': { paths: ['branding/current/logo.png'], record: 'branding/current' }
};

/** Per-object retry budget: full-jitter exponential backoff, the same policy the
 *  Mould Assessment app's synckit engine uses (base 30 s, cap 1 h, 6 strikes). */
var SY_BACKOFF_BASE_MS = 30 * 1000;
var SY_BACKOFF_CAP_MS  = 60 * 60 * 1000;
var SY_MAX_ERRORS      = 6;

/**
 * Hard cap on each bookkeeping map, so a long-lived account cannot grow the
 * receipt without bound (code review Q-01 F6).
 *
 * It was 2,000 and kept the FIRST 2,000 keys in insertion order, so on an
 * account with more objects than that the newest stamps were dropped on every
 * load and those objects (2 MiB photos among them) were re-uploaded, with a
 * reconcile GET each, on every pass for ever. Now:
 *   - the cap is 20,000 per map. One entry is a `<namespace>/<id>` key plus a
 *     64-hex digest, about 120 bytes, so a full stamps map is ~2.4 MB in the one
 *     IndexedDB record — tolerable, and a heavy professional account (100 visits
 *     x 100 photos plus a few thousand scans) still fits with room to spare;
 *   - the order IS the recency: every backup pass rewrites stamps/revs with the
 *     keys it confirmed live this pass LAST (`syRecencyOrder`), and the
 *     normaliser keeps the LAST N entries. Past the cap, what is evicted is what
 *     was confirmed longest ago — in practice stamps of records that no longer
 *     exist on this device — never the objects the pass just uploaded.
 * Per-namespace stores were considered and rejected: they need new
 * StorageService keys (and their allowlists) for no gain at this size.
 */
var SY_STATE_MAP_MAX = 20000;

/** The automatic mirror (I-08): which namespaces, and how long a burst of
 *  edits is coalesced before one upload. Replaces the Clerk unsafeMetadata
 *  mirrors, which are inert under Cognito. */
var SY_MIRROR_NAMESPACES = ['profile', 'branding'];
var SY_MIRROR_DELAY_MS = 2000;

/** syCloudSyncFlagOn — the CLOUD_SYNC flag, read outside React. Off ⇒ the
 *  automatic mirror is inert, exactly like the Cloud backup row. */
function syCloudSyncFlagOn() {
    return !(window.FeatureFlags && typeof window.FeatureFlags.isEnabled === 'function') ||
        !!window.FeatureFlags.isEnabled('CLOUD_SYNC');
}

/** Fields never written into `scans/<id>/metadata.json`: the thumbnail rides as
 *  its own object, and these two are transient/per-key by design (CR-02).
 *  `ownerSub` / `deviceOnly` (P-08, 2026-10-13) are THIS DEVICE's record of
 *  which account a scan belongs to: the cloud copy already lives under
 *  `users/{sub}/`, so they never ride the wire, never change a stamp and never
 *  make two copies compare different (the importer re-stamps on restore). */
var SY_SCAN_OMIT = ['thumbnail', 'previewURL', 'heatmapDataURL', 'ownerSub', 'deviceOnly'];

/* ------------------------------------------------------------------ *
 * Pure helpers — exported for the headless suite, no network, no state
 * ------------------------------------------------------------------ */

/** syValidPath — the Lambda's shape check, client-side. */
function syValidPath(path) {
    return typeof path === 'string' && SY_PATH_RE.test(path);
}

/** syChunk — batches of at most `size` (32, the Lambda's per-request cap). */
function syChunk(list, size) {
    var out = [];
    var n = size || SY_MAX_FILES;
    for (var i = 0; i < list.length; i += n) out.push(list.slice(i, i + n));
    return out;
}

/**
 * syDataUrlToBlob — `data:image/jpeg;base64,…` → `{ blob, contentType }`, or
 * null for anything that is not a base64 data URL. The Content-Type is read
 * from the data URL itself and must be sent back on the PUT byte for byte: the
 * Lambda binds it into the signature, so a mismatch is a 403 from S3.
 */
function syDataUrlToBlob(dataUrl) {
    if (typeof dataUrl !== 'string') return null;
    var match = /^data:([^;,]+);base64,(.*)$/.exec(dataUrl);
    if (!match) return null;
    try {
        var binary = atob(match[2]);
        var bytes = new Uint8Array(binary.length);
        for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
        return { blob: new Blob([bytes], { type: match[1] }), contentType: match[1] };
    } catch (err) {
        return null;
    }
}

/** syBlobToDataUrl — the restore-side inverse. Resolves null rather than rejecting. */
function syBlobToDataUrl(blob) {
    return new Promise(function (resolve) {
        try {
            var reader = new FileReader();
            reader.onload = function () { resolve(typeof reader.result === 'string' ? reader.result : null); };
            reader.onerror = function () { resolve(null); };
            reader.readAsDataURL(blob);
        } catch (err) {
            resolve(null);
        }
    });
}

/**
 * syStamp — the one timestamp that decides "newer", per namespace, in ms.
 *
 * Assessments, properties and branding carry an ISO `updatedAt`; the profile
 * keeps its own under `onboarding.updatedAt`; a scan has only `timestamp`, the
 * moment of capture, which never moves. Unreadable or absent ⇒ 0.
 */
function syStamp(namespace, record) {
    if (!record || typeof record !== 'object') return 0;
    var raw = null;
    // A scan's `updatedAt` is stamped by ScanStore on every edit (contract 1.0,
    // R1); a scan never edited since capture has only `timestamp`.
    if (namespace === 'scans') raw = (typeof record.updatedAt === 'string' && !isNaN(Date.parse(record.updatedAt))) ? record.updatedAt : record.timestamp;
    else if (namespace === 'profile') raw = (record.onboarding && record.onboarding.updatedAt) || null;
    // `properties` (review F2) lands in the general branch: `updatedAt` is
    // stamped by ScanStore on every property write, `createdAt` (a number) is
    // the floor for records written before that field existed.
    else raw = record.updatedAt || record.amendedAt || record.completedAt || record.createdAt || null;
    if (typeof raw === 'number') return isFinite(raw) ? raw : 0;
    if (typeof raw !== 'string' || !raw) return 0;
    var parsed = Date.parse(raw);
    return isNaN(parsed) ? 0 : parsed;
}

/**
 * syRemoteWins — THE merge rule (ADR-009, slice-6 note).
 *
 * Strictly newer, never "newer or equal": a tie is a local win, because the
 * local record is the one the user is looking at. A scan therefore never
 * replaces its local copy (its stamp is the immovable capture time) — it is
 * added when missing and left alone when present, exactly as the Swift restore
 * does.
 */
function syRemoteWins(namespace, local, remote) {
    if (!local) return true;
    if (!remote) return false;
    return syStamp(namespace, remote) > syStamp(namespace, local);
}

/* ------------------------------------------------------------------ *
 * Restore & conflict rules (contract 1.0, 2026-10-05) — pure helpers
 *
 * CLIENT-CONTRACT.md "Restore & conflict rules" is the spec; R-numbers below
 * point into it. Every helper here is deterministic and network-free, so the
 * Swift port can copy it field for field and the shared fixture
 * (the shared sync-merge fixture under shared-tests) can pin it.
 * ------------------------------------------------------------------ */

/**
 * syCanonical — JSON with object keys sorted (arrays keep their order).
 * "Are these two copies the same?" must not depend on the key order a client
 * happened to write, or every compare across clients would read as a change.
 */
function syCanonical(value) {
    if (value === undefined) return 'null';
    if (value === null || typeof value !== 'object') return JSON.stringify(value);
    if (Object.prototype.toString.call(value) === '[object Array]') {
        return '[' + value.map(syCanonical).join(',') + ']';
    }
    var keys = Object.keys(value).filter(function (k) { return value[k] !== undefined; }).sort();
    return '{' + keys.map(function (k) { return JSON.stringify(k) + ':' + syCanonical(value[k]); }).join(',') + '}';
}

/**
 * syReadObject — classify one downloaded JSON object (R6). Returns
 * `{ status, record }`:
 *
 *   'record'      — a v1 envelope, or a bare record (pre-envelope objects are
 *                   still valid backups);
 *   'unsupported' — written by a NEWER client: an envelope whose
 *                   `backupSchema` this build does not know, or a record whose
 *                   own `v` is above the version this build reads. Never
 *                   imported, never overwritten (R6), reported to the user;
 *   'invalid'     — not an object at all, or an envelope with no record.
 *
 * Unlike syUnwrap (kept for its existing callers), an unknown wrapper is NOT
 * handed back as if it were the record.
 */
function syReadObject(namespace, payload) {
    if (!payload || typeof payload !== 'object' || Object.prototype.toString.call(payload) === '[object Array]') {
        return { status: 'invalid', record: null };
    }
    var record = payload;
    if (Object.prototype.hasOwnProperty.call(payload, 'backupSchema')) {
        if (payload.backupSchema !== SY_BACKUP_SCHEMA) return { status: 'unsupported', record: null };
        if (!payload.record || typeof payload.record !== 'object') return { status: 'invalid', record: null };
        record = payload.record;
    }
    var known = SY_RECORD_VERSIONS[namespace];
    if (known && typeof record.v === 'number' && record.v > known) return { status: 'unsupported', record: null };
    return { status: 'record', record: record };
}

/**
 * syComparable — the form two copies are compared in: the backup form (what
 * the wire carries: a scan without its thumbnail, which is immutable per
 * scan), normalised for assessments so a raw remote and a normalised local
 * copy of the same content compare equal.
 */
function syComparable(namespace, record) {
    if (!record || typeof record !== 'object') return syCanonical(null);
    if (namespace === 'scans') return syCanonical(syScanMetadata(record));
    if (namespace === 'assessments' && typeof window.asMigrateAssessment === 'function') {
        var clean = window.asMigrateAssessment(record);
        if (clean) return syCanonical(clean);
    }
    return syCanonical(record);
}

/**
 * syMergeRecord — THE per-record merge (R3/R4): what this device should hold
 * when it has `local` and the cloud has `remote` for the same id.
 *
 *   • assessments — field-level `asMergeAssessment` (AssessmentStore) when both
 *     copies are readable: unions for rooms, samples, photos, tombstones and
 *     the amendment trail; newest-wins for scalars; completion one-way.
 *   • everything else — the whole record, strictly newer stamp wins, a tie
 *     keeps local (R3). A scan that loses keeps its local thumbnail when the
 *     winner carries none: a thumbnail is immutable per scan.
 */
function syMergeRecord(namespace, local, remote) {
    if (!local) return remote || null;
    if (!remote) return local;
    if (namespace === 'assessments' && typeof window.asMergeAssessment === 'function' &&
        typeof window.asMigrateAssessment === 'function' &&
        window.asMigrateAssessment(local) && window.asMigrateAssessment(remote)) {
        return window.asMergeAssessment(local, remote);
    }
    if (!syRemoteWins(namespace, local, remote)) return local;
    if (namespace === 'scans' && !remote.thumbnail && local.thumbnail) {
        var out = {};
        for (var k in remote) if (Object.prototype.hasOwnProperty.call(remote, k)) out[k] = remote[k];
        out.thumbnail = local.thumbnail;
        return out;
    }
    return remote;
}

/**
 * syTombstoneBody — the `deleted.json` written beside a deleted record (R5).
 * Its PRESENCE in a listing is the tombstone; the body is for humans and
 * exports (`kind`, `id`, `deletedAt`), and readers never need to fetch it.
 */
function syTombstoneBody(namespace, id, deletedAt) {
    return {
        backupSchema: SY_BACKUP_SCHEMA,
        kind: SY_KINDS[namespace] || namespace,
        deleted: true,
        id: id,
        deletedAt: deletedAt || new Date().toISOString()
    };
}

/**
 * syListingIndex — `GET /list` → `{ '<namespace>/<id>': { namespace, id,
 * names: { '<file>': '<path>' } } }`. Falls back to the legacy `scans` array
 * when an older Lambda answers without `items` (a scan appears in both, so
 * the fallback is never a sum).
 */
function syListingIndex(listing) {
    var items = (listing && listing.items) || [];
    if (items.length === 0 && listing && listing.scans && listing.scans.length) {
        items = listing.scans.map(function (entry) {
            return { namespace: 'scans', id: entry.scanId, files: entry.files || [] };
        });
    }
    var out = {};
    for (var i = 0; i < items.length; i++) {
        var item = items[i];
        if (!item || !item.namespace || !item.id) continue;
        var names = {};
        for (var f = 0; f < (item.files || []).length; f++) {
            var p = item.files[f] && item.files[f].path;
            if (typeof p === 'string') names[p.split('/').pop()] = p;
        }
        out[item.namespace + '/' + item.id] = { namespace: item.namespace, id: item.id, names: names };
    }
    return out;
}

/** syLocalRecord — this device's copy of `<namespace>/<id>` out of a backup's loaded data. */
function syLocalRecord(data, namespace, id) {
    if (namespace === 'profile') return data.profile || null;
    if (namespace === 'branding') return data.branding || null;
    var list = data[namespace] || [];
    for (var i = 0; i < list.length; i++) if (list[i] && list[i].id === id) return list[i];
    return null;
}

/** syDataOf — one record as the `data` shape syBackupItems reads. */
function syDataOf(namespace, record) {
    var out = {};
    if (namespace === 'profile' || namespace === 'branding') out[namespace] = record;
    else out[namespace] = [record];
    return out;
}

/** syDocName — the record object's file name in a namespace. */
function syDocName(namespace) {
    return (namespace === 'scans' || namespace === SY_PHOTO_NS) ? 'metadata.json' : 'record.json';
}

/**
 * syAssemble — downloaded objects → records per namespace, ready for the
 * importers (pure; restore and backup both use it).
 *
 * Images are re-attached first (a thumbnail to its scan, `logo.png` to the
 * branding record — where the logo lives, review F6). Returns
 * `{ records: { scans, properties, assessments, profile, branding },
 *    status: { '<ns>/<id>': 'record'|'unsupported'|'invalid'|'missing' },
 *    unreadable }`.
 */
function syAssemble(wanted, fetched) {
    var records = { scans: [], properties: [], assessments: [], profile: null, branding: null };
    var status = {};
    var thumbs = {};
    var logo = null;
    var logoMissing = false;
    var unreadable = 0;
    var i;
    for (i = 0; i < wanted.length; i++) {
        var entry = wanted[i];
        var raw = fetched[entry.path];
        if (entry.kind === 'thumbnail') { if (raw) thumbs[entry.id] = raw; continue; }
        if (entry.kind === 'logo') { if (raw) logo = raw; else logoMissing = true; continue; }
        var key = entry.namespace + '/' + entry.id;
        if (raw === undefined || raw === null) { status[key] = 'missing'; continue; }
        var read = syReadObject(entry.namespace, raw);
        status[key] = read.status;
        if (read.status === 'unsupported') { unreadable += 1; continue; }
        if (read.status !== 'record') continue;
        var payload = read.record;
        if (entry.namespace === 'profile') { records.profile = payload; continue; }
        if (entry.namespace === 'branding') { records.branding = payload; continue; }
        if (!records[entry.namespace]) continue;   // a namespace this client does not know
        payload.id = payload.id || entry.id;
        records[entry.namespace].push(payload);
    }
    for (i = 0; i < records.scans.length; i++) {
        // A restored scan arrives with its thumbnail; one without is still a
        // valid record (the card falls back to its placeholder).
        records.scans[i].thumbnail = thumbs[records.scans[i].id] || records.scans[i].thumbnail || '';
    }
    if (records.branding && records.branding.hasLogo === false) {
        // The record says the logo was removed (1.0.4, Q-13): a `logo.png`
        // still listed is a leftover whose delete has not landed yet (or a
        // Lambda older than 1.0.4 refused it). Never bring it back.
        records.branding.logoDataUrl = null;
    } else if (records.branding && logoMissing) {
        // The logo is listed but did not come down. `logo.png` is the ONLY
        // copy of it, so importing the record without it would erase the logo
        // here: treat the record as not downloaded (R9 — retry fetches both).
        records.branding = null;
        status['branding/current'] = 'missing';
    } else if (records.branding) {
        // `logo.png` is the only copy now: the record no longer carries one.
        records.branding.logoDataUrl = logo || records.branding.logoDataUrl || null;
    }
    // `hasLogo` is a wire field (syBackupItems), never part of the stored
    // record: stripped so a cloud copy compares equal to the local one.
    if (records.branding) delete records.branding.hasLogo;
    return { records: records, status: status, unreadable: unreadable };
}

/** syScanMetadata — the scan record as it is backed up: no image payloads. */
function syScanMetadata(scan) {
    var out = {};
    if (!scan || typeof scan !== 'object') return out;
    for (var k in scan) {
        if (!Object.prototype.hasOwnProperty.call(scan, k)) continue;
        if (SY_SCAN_OMIT.indexOf(k) !== -1) continue;
        out[k] = (scan[k] === undefined) ? null : scan[k];
    }
    return out;
}

/* ------------------------------------------------------------------ *
 * Scan ownership on a shared device (P-08, 2026-10-13) — pure helpers
 *
 * Local scans live in this browser, not in an account. Rozario's decision
 * (P-07): ASK ON FIRST BACKUP — a scan this account did not create is never
 * uploaded until the person says it is theirs. A scan saved while signed in
 * carries `ownerSub` (ScanStore stamps it); one saved signed out, or before
 * this field existed, has none and is "unlinked" until the person links it to
 * an account (`ownerSub` set) or keeps it on this device (`deviceOnly: true`).
 * ------------------------------------------------------------------ */

/**
 * syScanOwnerClass(scan, sub) — 'mine' (owned by `sub`: backed up), 'other'
 * (owned by another account: never uploaded, hidden), 'deviceOnly' (the person
 * chose to keep it here: never uploaded, visible to anyone on this device) or
 * 'unlinked' (no owner yet: never uploaded until someone answers the question).
 * A null `sub` (cannot tell who is signed in) owns nothing.
 */
function syScanOwnerClass(scan, sub) {
    var owner = (scan && typeof scan.ownerSub === 'string' && scan.ownerSub) ? scan.ownerSub : null;
    if (owner) return (sub && owner === sub) ? 'mine' : 'other';
    return (scan && scan.deviceOnly === true) ? 'deviceOnly' : 'unlinked';
}

/** syPartitionScans(scans, sub) — the scans split by syScanOwnerClass. */
function syPartitionScans(scans, sub) {
    var out = { mine: [], other: [], deviceOnly: [], unlinked: [] };
    for (var i = 0; i < (scans || []).length; i++) {
        var scan = scans[i];
        if (!scan || typeof scan !== 'object') continue;
        out[syScanOwnerClass(scan, sub)].push(scan);
    }
    return out;
}

/** syScanSummary — what the "are these yours?" question shows of a scan: no contents beyond a label and a date. */
function syScanSummary(scan) {
    return {
        id: scan.id,
        label: (typeof scan.roomName === 'string' && scan.roomName) ? scan.roomName
            : ((typeof scan.fileName === 'string' && scan.fileName) ? scan.fileName : 'Scan'),
        timestamp: (typeof scan.timestamp === 'number' && isFinite(scan.timestamp)) ? scan.timestamp : null
    };
}

/* ------------------------------------------------------------------ *
 * Component photos (2026-10-05) — pure helpers
 * ------------------------------------------------------------------ */

/**
 * syPhotoIndex — read the component photos out of assessment records, using
 * ONLY the contract fields (`rooms[].components[].attachments[]` and
 * `deletedPhotos[]`), so the Swift client can port it field for field.
 *
 * Returns `{ photos: [{ id, assessmentId, roomId, componentKey, createdAt, mime }],
 * deleted: { <photoId>: true }, roomDeleted: { <photoId>: true } }`. A
 * tombstoned id is never in `photos`, even when a stale copy still lists its
 * attachment. `roomDeleted` (code review Q-01 F9) names the attachments that
 * some copy still lists inside a room another copy has tombstoned: nothing will
 * ever upload or restore them again, so a restore that sees them listed in the
 * bucket queues their server delete.
 */
function syPhotoIndex(assessments) {
    var deleted = {};
    var deletedRooms = {};
    var roomDeleted = {};
    var photos = [];
    var seen = {};
    var list = assessments || [];
    var i, r, c, a;
    for (i = 0; i < list.length; i++) {
        var tombs = (list[i] && list[i].deletedPhotos) || [];
        for (var t = 0; t < tombs.length; t++) {
            if (tombs[t] && typeof tombs[t].photoId === 'string' && tombs[t].photoId) deleted[tombs[t].photoId] = true;
        }
        // A removed room (contract R5, `deletedItems`) takes its photos with it.
        var items = (list[i] && list[i].deletedItems) || [];
        for (var d = 0; d < items.length; d++) {
            if (items[d] && items[d].kind === 'room' && typeof items[d].id === 'string') deletedRooms[items[d].id] = true;
        }
    }
    for (i = 0; i < list.length; i++) {
        var assessment = list[i];
        if (!assessment || !assessment.id) continue;
        var rooms = assessment.rooms || [];
        for (r = 0; r < rooms.length; r++) {
            var comps = (rooms[r] && rooms[r].components) || [];
            if (rooms[r] && deletedRooms[rooms[r].id]) {
                for (c = 0; c < comps.length; c++) {
                    var gone = (comps[c] && comps[c].attachments) || [];
                    for (a = 0; a < gone.length; a++) {
                        if (gone[a] && typeof gone[a].id === 'string' && gone[a].id) roomDeleted[gone[a].id] = true;
                    }
                }
                continue;
            }
            for (c = 0; c < comps.length; c++) {
                var atts = (comps[c] && comps[c].attachments) || [];
                for (a = 0; a < atts.length; a++) {
                    var att = atts[a];
                    if (!att || typeof att.id !== 'string' || !att.id || deleted[att.id] || seen[att.id]) continue;
                    seen[att.id] = true;
                    photos.push({
                        id: att.id,
                        assessmentId: assessment.id,
                        roomId: rooms[r].id || null,
                        componentKey: comps[c].key || null,
                        createdAt: att.createdAt || null,
                        mime: 'image/jpeg'
                    });
                }
            }
        }
    }
    // An id still attached in a LIVE room somewhere is not orphaned.
    for (var live = 0; live < photos.length; live++) delete roomDeleted[photos[live].id];
    return { photos: photos, deleted: deleted, roomDeleted: roomDeleted };
}

/** syJsonSafe — a value reduced to JSON primitives (undefined dropped), or null. */
function syJsonSafe(value) {
    if (value === undefined || value === null) return null;
    try { return JSON.parse(JSON.stringify(value)); } catch (err) { return null; }
}

/**
 * syPhotoRecord — the `component-photos/<id>/metadata.json` record (wrapped in
 * the envelope like every other JSON object). `exif` is the summary
 * ExifService read from the ORIGINAL file at upload time, so a scan made from
 * the photo on another device still gets its captureContext; null when absent.
 */
function syPhotoRecord(photo, exif) {
    return {
        v: 1,
        id: photo.id,
        assessmentId: photo.assessmentId || null,
        roomId: photo.roomId || null,
        componentKey: photo.componentKey || null,
        createdAt: photo.createdAt || null,
        mime: 'image/jpeg',
        exif: syJsonSafe(exif)
    };
}

/** syByteLength — the UTF-8 byte length of a string: what a PUT's Content-Length will be. */
function syByteLength(text) {
    try { return new Blob([String(text)]).size; } catch (err) { return String(text).length; }
}

/**
 * Per-file PUT rules, client side (contract 1.0.1, the Lambda's PUT_RULES —
 * code review Q-01 F4). Checked BEFORE presigning, so an object the Lambda is
 * bound to refuse (FILE_TOO_LARGE / INVALID_CONTENT_TYPE) is reported as a
 * `validation` failure instead of costing a request. Keyed
 * `<namespace>/<file name>`. No SVG anywhere: a presigned GET would serve
 * script-capable markup.
 */
var SY_MIB = 1024 * 1024;
var SY_JSON = ['application/json'];
var SY_RASTER = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
var SY_PUT_LIMITS = {
    'scans/metadata.json':            { types: SY_JSON, max: SY_MIB },
    'scans/deleted.json':             { types: SY_JSON, max: 16 * 1024 },
    'scans/thumbnail.jpg':            { types: SY_RASTER, max: 2 * SY_MIB },
    'properties/record.json':         { types: SY_JSON, max: SY_MIB },
    'properties/deleted.json':        { types: SY_JSON, max: 16 * 1024 },
    'assessments/record.json':        { types: SY_JSON, max: 2 * SY_MIB },
    'profile/record.json':            { types: SY_JSON, max: SY_MIB },
    'branding/record.json':           { types: SY_JSON, max: SY_MIB },
    'branding/logo.png':              { types: SY_RASTER, max: 2 * SY_MIB },
    'component-photos/image.jpg':     { types: ['image/jpeg'], max: SY_PHOTO_MAX_BYTES },
    'component-photos/metadata.json': { types: SY_JSON, max: SY_PHOTO_META_MAX_BYTES }
};

/**
 * syPutProblem(file) — null when `{ path, contentType, contentLength }` is
 * within its PUT rule, else a short user-facing reason. An unknown file name
 * is a problem too (the Lambda answers INVALID_PATH since 1.0.1).
 */
function syPutProblem(file) {
    if (!file || typeof file.path !== 'string') return 'no path';
    var parts = file.path.split('/');
    var rule = SY_PUT_LIMITS[parts[0] + '/' + parts[parts.length - 1]];
    if (!rule) return 'not a file name the sync service accepts';
    if (rule.types.indexOf(file.contentType) === -1) return 'type ' + (file.contentType || 'unknown') + ' is not accepted';
    if (typeof file.contentLength !== 'number' || file.contentLength < 1) return 'empty file';
    if (file.contentLength > rule.max) {
        return 'too large to back up (' + Math.ceil(file.contentLength / 1024) + ' KB; the limit is ' + Math.round(rule.max / 1024) + ' KB)';
    }
    return null;
}

/**
 * syLogoReaddedSince(branding, removedAt) — does the stored branding record
 * carry a logo that was set AFTER the removal queued at `removedAt` (Q-13)?
 * `removedAt` is the removal's own `updatedAt`, so the stored record still
 * holding the old logo for the store's 300 ms persist debounce (same stamp or
 * older) is NOT a re-add, while any later write is (stamps only move forward,
 * R2). Then the queued `logo.png` delete would remove the new logo: drop it.
 */
function syLogoReaddedSince(branding, removedAt) {
    if (!branding || typeof branding.logoDataUrl !== 'string' || !branding.logoDataUrl) return false;
    var at = Date.parse(branding.updatedAt);
    var removed = Date.parse(removedAt);
    return !isNaN(at) && !isNaN(removed) && at > removed;
}

/** syPhotoPaths — the two object paths of one photo. */
function syPhotoPaths(photoId) {
    return [SY_PHOTO_NS + '/' + photoId + '/image.jpg', SY_PHOTO_NS + '/' + photoId + '/metadata.json'];
}

/* ------------------------------------------------------------------ *
 * The envelope (A2)
 * ------------------------------------------------------------------ */

/**
 * syEnvelope — wrap one record for the wire.
 *
 * `{ backupSchema, kind, rev, updatedAt, record }`, JSON primitives only, the
 * same shape for all five namespaces so a reader (a future export, the Swift
 * client, an ML pipeline) can tell what an object IS without knowing where it
 * came from. `updatedAt` is the record's own stamp re-expressed as ISO, so
 * ordering two copies needs no per-namespace knowledge; it is null for a scan,
 * whose only stamp is its immovable capture time.
 *
 * `rev` counts OBSERVED LOCAL CHANGES to this record: the client bumps it each
 * time the record's content hash differs from the one it last uploaded, and
 * keeps the counter in `mould-detect-sync-state.revs`. It is deliberately not a
 * field on the stored record — that would be a schema change across five record
 * types — so it is a per-device counter, carried for the reader and reserved as
 * the tiebreaker for equal `updatedAt` once BOTH clients store it. The merge
 * rule in this file does not read it yet; `syRemoteWins` is unchanged.
 */
function syEnvelope(namespace, record, rev) {
    var stamp = syStamp(namespace, record);
    return {
        backupSchema: SY_BACKUP_SCHEMA,
        kind: SY_KINDS[namespace] || namespace,
        rev: (typeof rev === 'number' && isFinite(rev) && rev > 0) ? rev : 1,
        updatedAt: stamp ? new Date(stamp).toISOString() : null,
        record: record
    };
}

/**
 * syUnwrap — the restore-side inverse, tolerant by contract.
 *
 * An envelope yields its `record`; anything else is returned as-is, because
 * objects written by the slice-6 client are bare records and a backup is not
 * something a user can be asked to re-take.
 */
function syUnwrap(payload) {
    if (payload && typeof payload === 'object' &&
        payload.backupSchema === SY_BACKUP_SCHEMA &&
        payload.record && typeof payload.record === 'object') {
        return payload.record;
    }
    return payload;
}

/* ------------------------------------------------------------------ *
 * The upload plan
 * ------------------------------------------------------------------ */

/**
 * syBackupItems — local data → one ITEM per namespace+id, the unit of both the
 * skip-unchanged check and the retry budget.
 *
 * Each item carries the record, its images as data URLs (decoded only when it
 * is actually going to be uploaded), and `stampSource`: the exact string that
 * is hashed to decide "has this changed?". `stampSource` includes the image
 * payloads even though they ride as separate objects — a logo swapped for
 * another logo changes nothing in `record.json` now that `logoDataUrl` is
 * stripped from it (review F6), and an unchanged-looking record must not mean
 * an unuploaded logo.
 */
function syBackupItems(data) {
    var items = [];
    var skipped = [];
    var d = data || {};
    var i;

    function push(namespace, id, record, images, stampExtra) {
        var docName = (namespace === 'scans' || namespace === SY_PHOTO_NS) ? 'metadata.json' : 'record.json';
        var path = namespace + '/' + id + '/' + docName;
        if (!syValidPath(path)) {
            skipped.push({ path: path, reason: namespace.replace(/s$/, '') + ' id is not a valid key segment' });
            return;
        }
        var body = JSON.stringify(record);
        items.push({
            namespace: namespace,
            id: id,
            key: namespace + '/' + id,
            docPath: path,
            record: record,
            images: images || [],
            stampSource: namespace + '|' + body + (stampExtra || '')
        });
    }

    var scans = d.scans || [];
    for (i = 0; i < scans.length; i++) {
        var scan = scans[i];
        if (!scan || !scan.id) { skipped.push({ path: 'scans/?/metadata.json', reason: 'scan has no id' }); continue; }
        var thumb = syDataUrlToBlob(scan.thumbnail);
        push('scans', scan.id, syScanMetadata(scan),
            thumb ? [{ name: 'thumbnail.jpg', blob: thumb.blob, contentType: thumb.contentType }] : [],
            thumb ? '|thumbnail:' + scan.thumbnail.length : '');
    }

    // Properties (review F2). A scan and an assessment both reference one by id;
    // backing them up without it restored records that render as "Unknown".
    var properties = d.properties || [];
    for (i = 0; i < properties.length; i++) {
        var property = properties[i];
        if (!property || !property.id) { skipped.push({ path: 'properties/?/record.json', reason: 'property has no id' }); continue; }
        push('properties', property.id, property, [], '');
    }

    var assessments = d.assessments || [];
    for (i = 0; i < assessments.length; i++) {
        var assessment = assessments[i];
        if (!assessment || !assessment.id) { skipped.push({ path: 'assessments/?/record.json', reason: 'assessment has no id' }); continue; }
        push('assessments', assessment.id, assessment, [], '');
    }

    if (d.profile) push('profile', 'current', d.profile, [], '');

    if (d.branding) {
        // The logo is uploaded ONCE, as `logo.png` (review F6). Leaving
        // `logoDataUrl` in the record too sent the same ~200 KB twice, and made
        // the two copies able to disagree; `logo.png` is now load-bearing, and
        // restore puts it back on the record.
        var brandingRecord = {};
        for (var k in d.branding) {
            if (!Object.prototype.hasOwnProperty.call(d.branding, k)) continue;
            brandingRecord[k] = (k === 'logoDataUrl') ? null : d.branding[k];
        }
        var logo = syDataUrlToBlob(d.branding.logoDataUrl);
        // `hasLogo` (1.0.4, Q-13): the record says whether a logo goes with
        // it, so a `logo.png` still in the bucket after the user removed the
        // logo (queued delete not landed yet, or a Lambda older than 1.0.4)
        // is ignored by restore/pull instead of brought back (syAssemble).
        // A wire field only: syAssemble strips it before the store sees it.
        brandingRecord.hasLogo = !!logo;
        push('branding', 'current', brandingRecord,
            logo ? [{ name: 'logo.png', blob: logo.blob, contentType: logo.contentType }] : [],
            logo ? '|logo:' + d.branding.logoDataUrl.length : '');
    }

    // Component photos (2026-10-05). Immutable per id: the stamp covers the
    // metadata only, and the image (plus exif) is read from IndexedDB just
    // before upload — see SyncService._uploadPhoto. `photo: true` marks it.
    var photos = d.photos || [];
    for (i = 0; i < photos.length; i++) {
        var photo = photos[i];
        if (!photo || !photo.id) continue;
        var before = items.length;
        push(SY_PHOTO_NS, photo.id, syPhotoRecord(photo, null), [], '');
        if (items.length > before) items[items.length - 1].photo = photo;
    }

    return { items: items, skipped: skipped };
}

/**
 * syItemFiles — one item → the `{ path, contentType, body }` descriptors to
 * PUT, with the JSON record wrapped in its envelope at revision `rev`.
 */
function syItemFiles(item, rev) {
    var files = [{
        path: item.docPath,
        contentType: 'application/json',
        body: JSON.stringify(syEnvelope(item.namespace, item.record, rev))
    }];
    for (var i = 0; i < item.images.length; i++) {
        files.push({
            path: item.namespace + '/' + item.id + '/' + item.images[i].name,
            contentType: item.images[i].contentType,
            body: item.images[i].blob
        });
    }
    // Every PUT declares its exact byte count (contract 1.0.1; it was
    // component-photos only): the Lambda checks it against the file's ceiling
    // and signs it, so S3 refuses any other size. UTF-8 bytes for JSON.
    for (var f = 0; f < files.length; f++) {
        files[f].contentLength = (typeof files[f].body === 'string') ? syByteLength(files[f].body) : files[f].body.size;
    }
    return files;
}

/**
 * syBackupFiles — the flat upload set, the shape the headless suite asserts.
 * `revs` is the `mould-detect-sync-state.revs` map; absent means "first backup",
 * which is revision 1 for everything.
 */
function syBackupFiles(data, revs) {
    var plan = syBackupItems(data);
    var files = [];
    for (var i = 0; i < plan.items.length; i++) {
        var item = plan.items[i];
        var rev = (revs && typeof revs[item.key] === 'number') ? revs[item.key] : 1;
        files = files.concat(syItemFiles(item, rev));
    }
    return { files: files, skipped: plan.skipped, items: plan.items };
}

/* ------------------------------------------------------------------ *
 * Content stamps (A1) and the retry budget
 * ------------------------------------------------------------------ */

/**
 * sySha256 — hex SHA-256 via `crypto.subtle`, the browser's answer to the Swift
 * client's `contentStamp`. Resolves NULL when the digest is unavailable (an
 * insecure origin has no `crypto.subtle`) or fails, and a null stamp means
 * "always upload": skipping an upload on a fingerprint we could not compute is
 * the one failure mode that loses data.
 */
function sySha256(text) {
    try {
        var subtle = window.crypto && (window.crypto.subtle || window.crypto.webkitSubtle);
        if (!subtle || typeof TextEncoder === 'undefined') return Promise.resolve(null);
        return Promise.resolve(subtle.digest('SHA-256', new TextEncoder().encode(String(text))))
            .then(function (buffer) {
                var bytes = new Uint8Array(buffer);
                var hex = '';
                for (var i = 0; i < bytes.length; i++) hex += ('0' + bytes[i].toString(16)).slice(-2);
                return hex;
            })['catch'](function () { return null; });
    } catch (err) {
        return Promise.resolve(null);
    }
}

/** syBackoffMs — full-jitter exponential backoff, synckit's policy verbatim. */
function syBackoffMs(errorCount, random) {
    var n = Math.min(errorCount || 1, SY_MAX_ERRORS);
    var ceiling = Math.min(SY_BACKOFF_CAP_MS, SY_BACKOFF_BASE_MS * Math.pow(2, n - 1));
    return Math.floor((typeof random === 'number' ? random : Math.random()) * ceiling);
}

/** syIsBackingOff — is this item still inside its retry window? */
function syIsBackingOff(entry, now) {
    if (!entry || typeof entry.retryAt !== 'string') return false;
    var at = Date.parse(entry.retryAt);
    return !isNaN(at) && at > (now || Date.now());
}

/**
 * syMergeById — merge a remote array into a local one by `id`.
 * Returns `{ merged, added, replaced, kept }`. Order: local order is preserved
 * and additions are appended, so a restore never reshuffles the user's list.
 */
function syMergeById(namespace, local, remote) {
    var merged = [];
    var index = {};
    var added = 0, replaced = 0, kept = 0;
    var i;

    for (i = 0; i < (local || []).length; i++) {
        merged.push(local[i]);
        if (local[i] && local[i].id) index[local[i].id] = i;
    }
    for (i = 0; i < (remote || []).length; i++) {
        var record = remote[i];
        if (!record || !record.id) continue;
        if (!Object.prototype.hasOwnProperty.call(index, record.id)) {
            index[record.id] = merged.length;
            merged.push(record);
            added += 1;
        } else if (syRemoteWins(namespace, merged[index[record.id]], record)) {
            // Whole-record newer-wins (R3); a scan keeps its local thumbnail
            // when the winning copy carries none (thumbnails are immutable).
            merged[index[record.id]] = (namespace === 'scans')
                ? syMergeRecord(namespace, merged[index[record.id]], record)
                : record;
            replaced += 1;
        } else {
            kept += 1;
        }
    }
    return { merged: merged, added: added, replaced: replaced, kept: kept };
}

/**
 * syNormaliseState — the allowlist writer for `mould-detect-sync-state` (v2).
 *
 * v2 adds `properties` to the counts and three bookkeeping maps keyed
 * `<namespace>/<id>`: `stamps` (the SHA-256 of what was last uploaded),
 * `revs` (how many local changes this device has seen) and `retries`
 * (`{ errorCount, retryAt }` for an object that failed). A v1 record is
 * migrated by carrying its scalars forward — the maps simply start empty,
 * which means "upload everything once", the safe direction.
 */
function syNormaliseState(raw) {
    var out = {
        v: SY_STATE_VERSION,
        lastBackupAt: null,
        lastRestoreAt: null,
        lastResult: null,
        errorCount: 0,
        counts: { scans: 0, properties: 0, assessments: 0, profile: 0, branding: 0, 'component-photos': 0 },
        stamps: {},
        revs: {},
        retries: {},
        // Queued server deletes (2026-10-05): `component-photos/<id>` →
        // { queuedAt, errorCount, retryAt|null }. Additive, so `v` stays 2.
        deletes: {},
        // Q-01 F1: the account (`sub`) this receipt belongs to, and the note the
        // sheet shows after a reset ({ at, reason: 'switched'|'unattributed' }),
        // cleared by the next clean backup. Additive, so `v` stays 2.
        accountSub: null,
        accountReset: null
    };
    if (!raw || typeof raw !== 'object') return out;
    if (raw.v !== SY_STATE_VERSION && raw.v !== 1) return out;
    if (typeof raw.lastBackupAt === 'string') out.lastBackupAt = raw.lastBackupAt;
    if (typeof raw.lastRestoreAt === 'string') out.lastRestoreAt = raw.lastRestoreAt;
    if (raw.lastResult === 'ok' || raw.lastResult === 'partial' || raw.lastResult === 'failed') out.lastResult = raw.lastResult;
    if (typeof raw.accountSub === 'string' && SY_SUB_RE.test(raw.accountSub)) out.accountSub = raw.accountSub;
    if (raw.accountReset && typeof raw.accountReset === 'object' && typeof raw.accountReset.at === 'string' &&
        !isNaN(Date.parse(raw.accountReset.at)) && (raw.accountReset.reason === 'switched' || raw.accountReset.reason === 'unattributed')) {
        out.accountReset = { at: raw.accountReset.at, reason: raw.accountReset.reason };
    }
    if (typeof raw.errorCount === 'number' && isFinite(raw.errorCount)) out.errorCount = raw.errorCount;
    var counts = (raw.counts && typeof raw.counts === 'object') ? raw.counts : {};
    var keys = ['scans', 'properties', 'assessments', 'profile', 'branding', 'component-photos'];
    for (var i = 0; i < keys.length; i++) {
        var n = counts[keys[i]];
        out.counts[keys[i]] = (typeof n === 'number' && isFinite(n)) ? n : 0;
    }
    if (raw.v !== SY_STATE_VERSION) return out;   // v1 carried its scalars; the maps start empty
    out.stamps  = syNormaliseMap(raw.stamps, 'string');
    out.revs    = syNormaliseMap(raw.revs, 'number');
    out.retries = syNormaliseMap(raw.retries, 'retry');
    out.deletes = syNormaliseMap(raw.deletes, 'delete');
    return out;
}

/**
 * syNormaliseMap — an allowlisted `<namespace>/<id>` → value map, bounded.
 * Past SY_STATE_MAP_MAX the LAST entries (most recently confirmed — see
 * syRecencyOrder) are kept and the oldest dropped (Q-01 F6).
 */
function syNormaliseMap(raw, kind, max) {
    var out = {};
    if (!raw || typeof raw !== 'object') return out;
    var cap = (typeof max === 'number' && max > 0) ? max : SY_STATE_MAP_MAX;
    var keys = [];
    for (var k0 in raw) {
        if (Object.prototype.hasOwnProperty.call(raw, k0)) keys.push(k0);
    }
    // Validate from the newest end so the cap counts only VALID entries.
    var kept = [];
    for (var i = keys.length - 1; i >= 0 && kept.length < cap; i--) {
        var key = keys[i];
        if (typeof key !== 'string' || key.indexOf('/') < 1) continue;
        var value = raw[key];
        if (kind === 'string') {
            if (typeof value !== 'string' || !/^[0-9a-f]{64}$/.test(value)) continue;
            out[key] = value;
        } else if (kind === 'number') {
            if (typeof value !== 'number' || !isFinite(value) || value < 1) continue;
            out[key] = Math.floor(value);
        } else if (kind === 'delete') {
            // Photo deletes (POST /delete), record tombstones (deleted.json, R5)
            // and the single-file deletes (the branding logo, 1.0.4).
            var ns = key.split('/')[0];
            var known = ns === SY_PHOTO_NS || SY_TOMBSTONE_WRITERS.indexOf(ns) !== -1 ||
                Object.prototype.hasOwnProperty.call(SY_FILE_DELETES, key);
            if (!known || !value || typeof value !== 'object') continue;
            var queuedAt = (typeof value.queuedAt === 'string' && !isNaN(Date.parse(value.queuedAt))) ? value.queuedAt : new Date().toISOString();
            var count = (typeof value.errorCount === 'number' && isFinite(value.errorCount)) ? Math.min(Math.max(Math.floor(value.errorCount), 0), SY_MAX_ERRORS) : 0;
            var at = (typeof value.retryAt === 'string' && !isNaN(Date.parse(value.retryAt))) ? value.retryAt : null;
            out[key] = { queuedAt: queuedAt, errorCount: count, retryAt: at };
        } else {
            if (!value || typeof value !== 'object') continue;
            var errors = (typeof value.errorCount === 'number' && isFinite(value.errorCount)) ? value.errorCount : 0;
            var retryAt = (typeof value.retryAt === 'string' && !isNaN(Date.parse(value.retryAt))) ? value.retryAt : null;
            if (!retryAt) continue;
            out[key] = { errorCount: Math.min(Math.max(Math.floor(errors), 1), SY_MAX_ERRORS), retryAt: retryAt };
        }
        kept.push(key);
    }
    // Rebuild oldest → newest so insertion order keeps meaning "recency".
    var ordered = {};
    for (var j = kept.length - 1; j >= 0; j--) ordered[kept[j]] = out[kept[j]];
    return ordered;
}

/**
 * syRecencyOrder(map, recentKeys) — the same map, re-inserted so the keys in
 * `recentKeys` (confirmed live by this pass) come LAST, in that order, after
 * every other key in its existing order. With syNormaliseMap keeping the last
 * N, this is least-recently-confirmed eviction (Q-01 F6).
 */
function syRecencyOrder(map, recentKeys) {
    var out = {};
    var recent = {};
    var i;
    var list = recentKeys || [];
    for (i = 0; i < list.length; i++) recent[list[i]] = true;
    for (var k in map) {
        if (Object.prototype.hasOwnProperty.call(map, k) && !recent[k]) out[k] = map[k];
    }
    for (i = 0; i < list.length; i++) {
        if (Object.prototype.hasOwnProperty.call(map, list[i])) out[list[i]] = map[list[i]];
    }
    return out;
}

/* ------------------------------------------------------------------ *
 * Account binding (code review Q-01 F1)
 * ------------------------------------------------------------------ */

/** The Lambda's own `sub` shape (auth.js SUB_RE): what becomes `users/{sub}/`. */
var SY_SUB_RE = /^[A-Za-z0-9_-]{1,128}$/;

/**
 * syTokenSub(token) — the `sub` claim of a bearer JWT, decoded WITHOUT
 * verifying it (the Lambda verifies; this only needs to know which account's
 * prefix the token will write to). null when the token is not a readable JWT
 * or the sub is not a shape the Lambda would accept.
 */
function syTokenSub(token) {
    try {
        var part = String(token || '').split('.')[1];
        if (!part) return null;
        var b64 = part.replace(/-/g, '+').replace(/_/g, '/');
        while (b64.length % 4) b64 += '=';
        var claims = JSON.parse(atob(b64));
        var sub = claims && claims.sub;
        return (typeof sub === 'string' && SY_SUB_RE.test(sub)) ? sub : null;
    } catch (err) {
        return null;
    }
}

/**
 * syAccountPatch(state, sub, nowIso) — what the sync state must become before
 * anything runs as `sub`. Pure. Returns null when nothing changes.
 *
 * The receipt (stamps, revs, retries, the delete queue, last backup/restore
 * times, counts) describes what THIS DEVICE did under ONE account. Under a
 * different `sub` the Lambda writes to a different, possibly empty, prefix, so:
 *   - stamps would make the first backup skip everything ("Everything is
 *     already backed up") while the new account holds nothing;
 *   - the delete queue would run account A's deletes as account B;
 *   - lastBackupAt/lastRestoreAt would wrongly open deleteRecord's "this
 *     device has synced before" gate.
 * So a different sub — a shared device's sign-in switch, or the Clerk →
 * Cognito cutover where every person gets a new sub — resets all of it.
 * A receipt with history but NO recorded sub (written before this field
 * existed) cannot be attributed and is reset the same way: one full upload is
 * the safe direction. An empty receipt simply records the sub.
 */
function syAccountPatch(state, sub, nowIso) {
    if (!sub || !SY_SUB_RE.test(sub)) return null;
    var st = state || {};
    if (st.accountSub === sub) return null;
    var has = function (m) { return !!(m && typeof m === 'object' && Object.keys(m).length); };
    var history = !!(st.lastBackupAt || st.lastRestoreAt || has(st.stamps) || has(st.revs) || has(st.retries) || has(st.deletes));
    if (!st.accountSub && !history) return { accountSub: sub };
    return {
        accountSub: sub,
        accountReset: { at: nowIso || new Date().toISOString(), reason: st.accountSub ? 'switched' : 'unattributed' },
        lastBackupAt: null,
        lastRestoreAt: null,
        lastResult: null,
        errorCount: 0,
        counts: { scans: 0, properties: 0, assessments: 0, profile: 0, branding: 0, 'component-photos': 0 },
        stamps: {},
        revs: {},
        retries: {},
        deletes: {}
    };
}

/**
 * syResult — the single result shape both entry points resolve.
 *
 * `needsReload` is GONE (review F1): a restore now lands in the live stores, so
 * there is nothing for a reload to fix. `unchanged` and `deferred` are the two
 * counts a skip-unchanged backup owes the user — "nothing happened" and "I did
 * not try that one yet" are different sentences.
 */
function syResult(fields) {
    // `merged`: records combined with (or taken from) the cloud copy because
    // another device changed them (R4); `removed`: local records a cloud
    // tombstone deleted (R5); `unreadable`: cloud objects written by a newer
    // version of the app, left alone (R6). Counts only, JSON primitives.
    var out = { ok: false, uploaded: 0, unchanged: 0, deferred: 0, restored: 0, deleted: 0, skipped: 0,
                merged: 0, removed: 0, unreadable: 0, errors: [], reason: null };
    for (var k in (fields || {})) {
        if (Object.prototype.hasOwnProperty.call(fields, k)) out[k] = fields[k];
    }
    return out;
}

/* ------------------------------------------------------------------ *
 * Sync health — count-only outcome events (pure helpers)
 *
 * WHY: until 2026-10-05 a failed backup or restore was visible only on the
 * device that hit it. The Cloud Saves dashboard (cloud-data-dashboard/, served at /architecture/cloud-data/)
 * needs "which accounts have read or save issues" without ever learning WHAT
 * failed to save. So each pass reports, through the existing POST /events
 * pipeline and its ADR-010 rules, at most one event per (error class ×
 * namespace) with a file COUNT — never a path, a record id, a key, an error
 * message or a payload. The class is decided here from the HTTP status and the
 * stage, never from free text. Same three off switches as MetricsService
 * (flag, endpoint, profile opt-out) because it rides MetricsService's state.
 * ------------------------------------------------------------------ */

/** syNamespaceOf — the first path segment if it is one of ours, else 'none'. */
function syNamespaceOf(path) {
    var head = (typeof path === 'string') ? path.split('/')[0] : '';
    return SY_NAMESPACES.indexOf(head) === -1 ? 'none' : head;
}

/**
 * syErrorClass — an Error (or HTTP-typed Error from `_httpError`) plus the
 * stage it happened in → one of the schema's error classes.
 *   stage: 'presign' | 'put' | 'get' | 'list' | 'delete' | 'validation' | 'import' | 'local'
 */
function syErrorClass(err, stage) {
    var status = (err && typeof err.status === 'number') ? err.status : null;
    var msg = (err && typeof err.message === 'string') ? err.message : '';
    if (status === 401 || /^signed out/.test(msg)) return 'auth';
    if (stage === 'validation') return 'validation';
    if (stage === 'import') return 'import';
    if (/^no presigned URL/.test(msg)) return 'presign';
    if (err && err.name === 'SyntaxError') return 'parse';
    if (status === null) return (err && err.name === 'TypeError') ? 'network' : 'unknown';
    if (stage === 'delete') return 'delete';
    if (stage === 'presign') return 'presign';
    if (stage === 'list') return 'list';
    if (stage === 'put') return 's3_put';
    if (stage === 'get') return 's3_get';
    return 'unknown';
}

/** syHealthTally — group failures by (class, namespace), counting files. */
function syHealthTally() {
    var groups = {};
    return {
        add: function (errorClass, namespace, files) {
            var key = errorClass + '|' + namespace;
            if (!groups[key]) groups[key] = { errorClass: errorClass, namespace: namespace, files: 0 };
            groups[key].files += (typeof files === 'number' ? files : 1);
        },
        size: function () { return Object.keys(groups).length; },
        events: function (name) {
            return Object.keys(groups).sort().map(function (k) {
                return { name: name, props: { errorClass: groups[k].errorClass, namespace: groups[k].namespace, files: groups[k].files } };
            });
        }
    };
}

/**
 * syCleanHealthEvent — one event against the allowlist, MetricsService-style:
 * unknown name ⇒ null; unknown prop dropped; an enum value outside its list
 * becomes 'unknown' if that is allowed, else the prop is dropped; ints clamped
 * out of range are dropped. What leaves here the Lambda will accept.
 */
function syCleanHealthEvent(schema, raw) {
    if (!schema || !schema.events || !raw || !Object.prototype.hasOwnProperty.call(schema.events, raw.name)) return null;
    var allowed = schema.events[raw.name];
    var props = {};
    var input = raw.props || {};
    for (var key in allowed) {
        if (!Object.prototype.hasOwnProperty.call(allowed, key)) continue;
        var spec = allowed[key];
        var value = input[key];
        if (value === undefined || value === null) continue;
        if (spec.type === 'enum') {
            if (spec.values.indexOf(value) !== -1) props[key] = value;
            else if (spec.values.indexOf('unknown') !== -1) props[key] = 'unknown';
            else if (spec.values.indexOf('none') !== -1) props[key] = 'none';
        } else if (spec.type === 'int') {
            if (typeof value === 'number' && Math.floor(value) === value && value >= spec.min && value <= spec.max) props[key] = value;
        }
    }
    return { name: raw.name, at: raw.at || new Date().toISOString(), props: props };
}

/** Bounded, best-effort localStorage queue: a failure reported while offline
 *  goes out with the next pass. Never throws. */
function syHealthQueueLoad() {
    try {
        var raw = window.localStorage.getItem(SY_HEALTH_QUEUE_KEY);
        var list = raw ? JSON.parse(raw) : [];
        return Array.isArray(list) ? list : [];
    } catch (err) { return []; }
}

function syHealthQueueSave(list) {
    try {
        if (!list || list.length === 0) window.localStorage.removeItem(SY_HEALTH_QUEUE_KEY);
        else window.localStorage.setItem(SY_HEALTH_QUEUE_KEY, JSON.stringify(list.slice(-SY_HEALTH_MAX)));
    } catch (err) { /* storage full or blocked — the report is simply lost */ }
}

/* ------------------------------------------------------------------ *
 * The service
 * ------------------------------------------------------------------ */

var SyncService = (function () {

    var _endpoint = '';
    var _loaded = false;

    /**
     * Single-flight (synckit's `_passPromise`): one in-flight pass, and a second
     * call for the SAME operation resolves with it rather than starting a
     * competing run or answering "already running" to a caller who simply
     * double-tapped. A different operation while one is in flight still gets the
     * friendly stop — backing up mid-restore is a question, not a retry.
     */
    var _pass = null;          // { kind: 'backup'|'restore', promise }

    /** The registered store importers (review F1): namespace → fn(records). */
    var _importers = {};

    /** Pending/in-flight automatic mirrors (I-08): namespace → { record, getToken, timer, running }. */
    var _mirrors = {};

    /* --- config ---------------------------------------------------- */

    /**
     * init — load `sync-config.json` once. Never rejects: an unreachable or
     * malformed config leaves the endpoint empty, which means "sync is off".
     * Called from the boot chain in App.jsx, next to FeatureFlags.init().
     */
    function init() {
        if (_loaded) return Promise.resolve(_endpoint);
        return fetch(SY_CONFIG_URL)
            .then(function (res) {
                if (!res.ok) throw new Error('sync-config.json: ' + res.status);
                return res.json();
            })
            .then(function (cfg) {
                var url = (cfg && typeof cfg.syncEndpoint === 'string') ? cfg.syncEndpoint.trim() : '';
                _endpoint = url.replace(/\/+$/, '');
                _loaded = true;
                return _endpoint;
            })
            ['catch'](function (err) {
                console.warn('[SyncService] config not loaded, cloud backup stays off:', err.message);
                _endpoint = '';
                _loaded = true;
                return '';
            });
    }

    /** isConfigured — the Swift client's `isConfigured`: an empty URL disables sync entirely. */
    function isConfigured() { return !!_endpoint; }

    function endpoint() { return _endpoint; }

    /* --- store importers (review F1) -------------------------------- */

    /**
     * registerImporter(namespace, fn) — a provider lends this service the ONE
     * way into its live state, for the lifetime of the provider.
     *
     * `fn(records)` merges (newer-wins) and returns `{ added, replaced, kept }`,
     * or null when that store never hydrated and therefore has no idea what it
     * would be merging against. Returns an unregister function for the effect's
     * cleanup — a namespace whose provider has unmounted is reported as skipped,
     * which is the honest answer, rather than written to behind its back.
     */
    function registerImporter(namespace, fn) {
        if (typeof namespace !== 'string' || typeof fn !== 'function') return function () {};
        _importers[namespace] = fn;
        return function () {
            if (_importers[namespace] === fn) delete _importers[namespace];
        };
    }

    /** importers — the namespaces a restore can currently write to (tests read it). */
    function importers() {
        var out = [];
        for (var i = 0; i < SY_NAMESPACES.length; i++) {
            if (_importers[SY_NAMESPACES[i]]) out.push(SY_NAMESPACES[i]);
        }
        return out;
    }

    /* --- persisted state ------------------------------------------- */

    function getState() {
        return StorageService.loadSyncState()
            .then(function (raw) { return syNormaliseState(raw); })
            ['catch'](function () { return syNormaliseState(null); });
    }

    /**
     * _updateState(fn) — read-modify-write of the sync state, SERIALISED, so a
     * delete queued while a pass is finishing cannot be lost to the pass's own
     * write (2026-10-05). `fn(state)` returns the patch.
     */
    var _stateChain = Promise.resolve();
    function _updateState(fn) {
        var step = _stateChain.then(function () {
            return getState().then(function (state) {
                var next = syNormaliseState(Object.assign({}, state, fn(state) || {}, { v: SY_STATE_VERSION }));
                return StorageService.saveSyncState(next).then(function () { return next; });
            });
        })['catch'](function (err) {
            console.warn('[SyncService] could not record sync state:', err && err.message);
            return null;
        });
        _stateChain = step;
        return step;
    }

    function _saveState(patch) {
        return _updateState(function () { return patch; });
    }

    /* --- token ------------------------------------------------------ */

    /**
     * _token — the signed-in access token for `Authorization: Bearer`, fetched per
     * request (short-lived; CLIENT-CONTRACT.md "Auth"). `override` is
     * `useAuth().getToken` when the sheet passes it; the fallback reads
     * `window.mdAuth` (AuthService) so the service is usable without the React tree.
     * Always resolves — null, never a rejection.
     */
    function _token(override) {
        try {
            if (typeof override === 'function') {
                return Promise.resolve(override())['catch'](function () { return null; });
            }
            // AuthService (Cognito or Clerk per AUTH_PROVIDER); never rejects.
            if (window.mdAuth && typeof window.mdAuth.getAccessToken === 'function') {
                return Promise.resolve(window.mdAuth.getAccessToken())['catch'](function () { return null; });
            }
        } catch (err) {
            /* falls through to null */
        }
        return Promise.resolve(null);
    }

    /**
     * _currentSub(token) — whose prefix this token writes to: its own `sub`
     * claim when it is a JWT (Cognito access token, Clerk session token), else
     * the signed-in user's sub from AuthService. null = cannot tell, and then
     * the receipt is left exactly as it was (the pre-Q-01 behaviour).
     */
    function _currentSub(token) {
        var sub = syTokenSub(token);
        if (sub) return sub;
        try {
            var user = (window.mdAuth && typeof window.mdAuth.getUser === 'function') ? window.mdAuth.getUser() : null;
            if (user && typeof user.sub === 'string' && SY_SUB_RE.test(user.sub)) return user.sub;
        } catch (err) { /* fall through */ }
        return null;
    }

    /**
     * _bindAccount(token) — Q-01 F1. Before anything reads the receipt to
     * decide what to send, make sure it belongs to the account this token
     * writes as (syAccountPatch). Serialised with every other state write.
     * Resolves the (possibly reset) state; never rejects.
     */
    function _bindAccount(token) {
        var sub = _currentSub(token);
        // P-08: the account this pass imports as (handed to the importers).
        // Set BEFORE the early return so a pass that cannot tell who is signed
        // in never inherits the previous account's sub (the scans importer
        // then falls back to the store's own auth state, and claims nothing).
        _activeSub = sub || null;
        if (!sub) return getState();
        return getState().then(function (state) {
            if (!syAccountPatch(state, sub)) return state;
            return _updateState(function (fresh) {
                var patch = syAccountPatch(fresh, sub, new Date().toISOString());
                if (patch && patch.accountReset) {
                    console.info('[SyncService] Signed-in account differs from this device\'s sync receipt; receipt reset (' + patch.accountReset.reason + ').');
                }
                return patch || {};
            }).then(function (next) { return next || getState(); });
        })['catch'](function () { return getState(); });
    }

    /** P-08: the `sub` the current pass runs as — the scans importer stamps restored scans with it. */
    var _activeSub = null;

    /** _boundToken(override) — `_token`, then `_bindAccount` when signed in. */
    function _boundToken(override) {
        return _token(override).then(function (token) {
            if (!token) return null;
            return _bindAccount(token).then(function () { return token; });
        });
    }

    /**
     * bindAccount(options) — the sheet calls this on open so a reset (and its
     * note) is visible BEFORE the person presses anything. Resolves the state.
     */
    function bindAccount(options) {
        var opts = options || {};
        if (!isConfigured()) return getState();
        return _token(opts.getToken).then(function (token) {
            return token ? _bindAccount(token) : getState();
        })['catch'](function () { return getState(); });
    }

    /* --- scan ownership (P-08) -------------------------------------- */

    /** The ScanStore's ownership writer, lent for the provider's lifetime (like an importer). */
    var _scanLinker = null;

    /**
     * registerScanLinker(fn) — `fn(ids, patch)` sets `{ ownerSub }` or
     * `{ deviceOnly: true }` on those scans in LIVE state and resolves once the
     * write is on disk (a backup reads IndexedDB straight after). Returns the
     * unregister function.
     */
    function registerScanLinker(fn) {
        if (typeof fn !== 'function') return function () {};
        _scanLinker = fn;
        return function () { if (_scanLinker === fn) _scanLinker = null; };
    }

    /**
     * scanOwnership(options) — what the first-backup question needs: who is
     * signed in, and the scans on this device no account has claimed
     * (`unlinked`) or that were kept on this device (`deviceOnly`), as
     * summaries. Reads the same IndexedDB rows a backup reads. Never rejects.
     */
    function scanOwnership(options) {
        var opts = options || {};
        return Promise.all([_token(opts.getToken), StorageService.loadScans()['catch'](function () { return []; })])
            .then(function (r) {
                var sub = r[0] ? _currentSub(r[0]) : null;
                var parts = syPartitionScans(Array.isArray(r[1]) ? r[1] : [], sub);
                return {
                    sub: sub,
                    mine: parts.mine.length,
                    other: parts.other.length,
                    unlinked: parts.unlinked.map(syScanSummary),
                    deviceOnly: parts.deviceOnly.map(syScanSummary)
                };
            })['catch'](function () { return { sub: null, mine: 0, other: 0, unlinked: [], deviceOnly: [] }; });
    }

    /**
     * linkScans(ids, decision, options) — the person's answer. 'link' gives
     * the scans to the signed-in account (they are backed up from now on and
     * show only to that account); 'device' keeps them on this device (never
     * uploaded, visible to anyone here). Resolves the number changed; 0 when
     * nobody is signed in or the store is not mounted. Never rejects.
     */
    function linkScans(ids, decision, options) {
        var opts = options || {};
        var list = Array.isArray(ids) ? ids.filter(function (id) { return typeof id === 'string' && id; }) : [];
        if (!list.length || !_scanLinker || (decision !== 'link' && decision !== 'device')) return Promise.resolve(0);
        return _token(opts.getToken).then(function (token) {
            var sub = token ? _currentSub(token) : null;
            if (!sub) return 0;
            var patch = (decision === 'link') ? { ownerSub: sub, deviceOnly: false } : { deviceOnly: true };
            return Promise.resolve(_scanLinker(list, patch)).then(function (n) { return n || 0; });
        })['catch'](function () { return 0; });
    }

    /* --- HTTP ------------------------------------------------------- */

    /**
     * _httpError — a non-2xx turned into a typed Error. `status` present is the
     * marker that says "the server answered"; its absence means the request
     * never completed, which is the only thing `_retry` retries.
     */
    function _httpError(label, res, code) {
        var err = new Error(label + ' ' + res.status + (code ? ' ' + code : ''));
        err.status = res.status;
        err.code = code || null;
        return err;
    }

    /**
     * _retry — one retry on a NETWORK failure; never on an HTTP status.
     *
     * Slice 6 retried everything thrown, which meant a 400 (a contract bug), a
     * 403 (a mismatched Content-Type) and a 404 (a missing object) each cost a
     * second round trip to be told the same thing (review F5). The server
     * answering IS the answer.
     *
     * The single exception is 401: a short-lived Clerk JWT can expire between
     * the presign and the call that uses it, so `refresh` is asked for a fresh
     * token ONCE and the call is repeated once with it. A second 401 is a real
     * "signed out" and is surfaced.
     *
     * `run(token)` is called with the fresh token on that retry; callers that
     * carry no token ignore the argument.
     */
    function _retry(run, refresh) {
        return run()['catch'](function (err) {
            var status = err && err.status;
            if (status === 401 && typeof refresh === 'function') {
                return refresh().then(function (fresh) {
                    if (!fresh) throw err;
                    return run(fresh);
                });
            }
            if (typeof status === 'number') throw err;   // the server answered — repeating it changes nothing
            return run();                                // a thrown fetch: offline, DNS, a dropped connection
        });
    }

    function _presign(method, files, token, refresh) {
        var body = { method: method, files: [] };
        for (var i = 0; i < files.length; i++) {
            var entry = { path: files[i].path };
            if (method === 'PUT') entry.contentType = files[i].contentType;
            if (method === 'PUT' && typeof files[i].contentLength === 'number') entry.contentLength = files[i].contentLength;
            body.files.push(entry);
        }
        return _retry(function (freshToken) {
            return fetch(_endpoint + '/presign', {
                method: 'POST',
                headers: { 'Authorization': 'Bearer ' + (freshToken || token), 'Content-Type': 'application/json' },
                body: JSON.stringify(body)
            }).then(function (res) {
                if (!res.ok) {
                    return res.json()['catch'](function () { return {}; }).then(function (payload) {
                        throw _httpError('presign', res, payload.code);
                    });
                }
                return res.json();
            }).then(function (payload) {
                var map = {};
                var urls = (payload && payload.urls) || [];
                for (var u = 0; u < urls.length; u++) map[urls[u].path] = urls[u].url;
                return map;
            });
        }, refresh);
    }

    // The S3 calls carry no Authorization header — a presigned URL IS the
    // credential — so there is no token to refresh: a 403 from an expired URL
    // is surfaced, and the next run presigns again.
    function _put(url, body, contentType) {
        return _retry(function () {
            return fetch(url, { method: 'PUT', headers: { 'Content-Type': contentType }, body: body })
                .then(function (res) {
                    if (!res.ok) throw _httpError('PUT', res);
                    return true;
                });
        });
    }

    function _getBlob(url) {
        return _retry(function () {
            return fetch(url).then(function (res) {
                if (!res.ok) throw _httpError('GET', res);
                return res.blob();
            });
        });
    }

    /** _deleteRemote — POST /delete { files: [{ path }] } → { deleted: [path] }. */
    function _deleteRemote(paths, token, refresh) {
        return _retry(function (freshToken) {
            return fetch(_endpoint + '/delete', {
                method: 'POST',
                headers: { 'Authorization': 'Bearer ' + (freshToken || token), 'Content-Type': 'application/json' },
                body: JSON.stringify({ files: paths.map(function (p) { return { path: p }; }) })
            }).then(function (res) {
                if (!res.ok) {
                    return res.json()['catch'](function () { return {}; }).then(function (payload) {
                        throw _httpError('delete', res, payload.code);
                    });
                }
                return res.json();
            });
        }, refresh);
    }

    /**
     * _purgeRemote — POST /delete { records: [{ namespace, id }] } (contract
     * 1.0.2): the Lambda deletes every object of each record except its
     * `deleted.json`, and refuses (409 TOMBSTONE_REQUIRED) a record whose
     * tombstone is not there yet. Idempotent.
     */
    function _purgeRemote(keys, token, refresh) {
        var records = keys.map(function (k) {
            var slash = k.indexOf('/');
            return { namespace: k.slice(0, slash), id: k.slice(slash + 1) };
        });
        return _retry(function (freshToken) {
            return fetch(_endpoint + '/delete', {
                method: 'POST',
                headers: { 'Authorization': 'Bearer ' + (freshToken || token), 'Content-Type': 'application/json' },
                body: JSON.stringify({ records: records })
            }).then(function (res) {
                if (!res.ok) {
                    return res.json()['catch'](function () { return {}; }).then(function (payload) {
                        throw _httpError('purge', res, payload.code);
                    });
                }
                return res.json();
            });
        }, refresh);
    }

    function _list(token, refresh) {
        return _retry(function (freshToken) {
            return fetch(_endpoint + '/list', { headers: { 'Authorization': 'Bearer ' + (freshToken || token) } })
                .then(function (res) {
                    if (!res.ok) throw _httpError('list', res);
                    return res.json();
                });
        }, refresh);
    }

    /* --- sync health (count-only outcome events) ---------------------- */

    var _healthSchema = null;

    function _loadHealthSchema() {
        if (_healthSchema) return Promise.resolve(_healthSchema);
        return fetch(SY_HEALTH_SCHEMA_URL)
            .then(function (res) { return res.ok ? res.json() : null; })
            .then(function (schema) { _healthSchema = schema; return schema; })
            ['catch'](function () { return null; });
    }

    /**
     * _reportHealth — fire-and-forget POST of this pass's outcome events (plus
     * anything queued from an earlier offline pass) to `{endpoint}/events`.
     *
     * It rides MetricsService's switches rather than inventing its own: no
     * MetricsService, the PRODUCT_METRICS flag off, no endpoint, or
     * `profile.metricsOptOut` ⇒ nothing is sent and nothing is queued. The
     * visitor id is MetricsService's, so these events count exactly like the
     * product events do (ADR-010 D2: the account comes from the JWT, never
     * from the body). Never throws, never delays the pass that called it.
     */
    function _reportHealth(rawEvents, tokenOverride) {
        try {
            if (!_endpoint || !window.MetricsService || typeof MetricsService.state !== 'function') return;
            var st = null;
            Promise.resolve(MetricsService.init()).then(function () {
                st = MetricsService.state();
                // Switches first, so a disabled pipeline costs not even the schema fetch.
                if (!st || !st.enabled || st.optOut || !st.endpoint || !st.visitorId) return null;
                return _loadHealthSchema();
            }).then(function (schema) {
                if (!schema) return null;
                var fresh = [];
                for (var i = 0; i < rawEvents.length; i++) {
                    var clean = syCleanHealthEvent(schema, rawEvents[i]);
                    if (clean) fresh.push(clean);
                }
                var queue = syHealthQueueLoad().concat(fresh).slice(-SY_HEALTH_MAX);
                if (queue.length === 0) return null;
                var wire = queue.map(function (e) { return { name: e.name, at: e.at, visitorId: st.visitorId, props: e.props }; });
                return _token(tokenOverride).then(function (token) {
                    var headers = { 'content-type': 'application/json' };
                    if (token) headers.authorization = 'Bearer ' + token;
                    return fetch(st.endpoint, { method: 'POST', headers: headers, body: JSON.stringify({ events: wire }) });
                }).then(function (res) {
                    // 2xx: delivered. 4xx (not 429): the server will never accept
                    // this batch, so keeping it would block every later report.
                    if (res && (res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429))) syHealthQueueSave([]);
                    else syHealthQueueSave(queue);
                })['catch'](function () { syHealthQueueSave(queue); });
            })['catch'](function () { return null; });
        } catch (err) {
            /* measurement never surfaces to the user */
        }
    }

    /* --- component-photo deletes (2026-10-05) ------------------------ */

    /**
     * _flushDeletes — send every queued photo delete that is not backing off
     * (all of them when `force`). Never rejects; resolves
     * `{ deleted, failed, errors[] }` and reports failures into `health` when
     * one is passed (a backup pass). A 400 is a contract bug, not a transient:
     * that entry is dropped (and logged) rather than retried forever.
     */
    function _flushDeletes(token, refresh, health, force) {
        // Q-01 F1: never send one account's queued deletes with another's token.
        return _bindAccount(token).then(function (state) {
            var now = Date.now();
            var photoKeys = [];
            var recordKeys = [];
            var fileKeys = [];
            for (var key in state.deletes) {
                if (!Object.prototype.hasOwnProperty.call(state.deletes, key)) continue;
                if (!force && syIsBackingOff(state.deletes[key], now)) continue;
                if (key.indexOf(SY_PHOTO_NS + '/') === 0) photoKeys.push(key);
                else if (Object.prototype.hasOwnProperty.call(SY_FILE_DELETES, key)) fileKeys.push(key);
                else recordKeys.push(key);
            }
            var outcome = { deleted: 0, failed: 0, errors: [] };
            if (photoKeys.length === 0 && recordKeys.length === 0 && fileKeys.length === 0) return outcome;
            var done = [];
            var failed = [];
            var dropped = [];
            var filesDone = [];
            // `retryAlways`: a purge whose tombstone already landed is never
            // dropped, even on a 400 (e.g. a Lambda older than 1.0.2) — the
            // whole point of the entry is that the scan's data leaves S3.
            var settle = function (chunk, err, label, namespace, files, retryAlways) {
                var status = err && err.status;
                if (status === 400 && !retryAlways) {
                    console.error('[SyncService] delete refused as a bad request (dropped):', err.message);
                    dropped = dropped.concat(chunk);
                } else {
                    failed = failed.concat(chunk);
                }
                outcome.errors.push(label + ': ' + ((err && err.message) || 'failed'));
                if (health) health.add(syErrorClass(err, 'delete'), namespace, files);
            };
            var chain = Promise.resolve();
            // Photos: the server deletes both objects (POST /delete, ADR-013).
            syChunk(photoKeys, SY_DELETE_IDS_PER_CALL).forEach(function (chunk) {
                chain = chain.then(function () {
                    var paths = [];
                    chunk.forEach(function (k) { paths = paths.concat(syPhotoPaths(k.slice(SY_PHOTO_NS.length + 1))); });
                    return _deleteRemote(paths, token, refresh).then(function () {
                        done = done.concat(chunk);
                    })['catch'](function (err) {
                        settle(chunk, err, 'delete ' + chunk.length + ' photo(s)', SY_PHOTO_NS, chunk.length * 2);
                    });
                });
            });
            // Single files (1.0.4, Q-13): the removed branding logo. Its own
            // call, so a Lambda older than 1.0.4 (400 DELETE_NOT_ALLOWED)
            // never holds back photo deletes; that 400 keeps it queued
            // (`retryAlways`) — the logo must leave S3 once the Lambda is
            // updated. A logo added again AFTER the removal (the stored
            // branding has a logo and is stamped later than the queued
            // removal) supersedes the delete: dropped, nothing sent.
            if (fileKeys.length) {
                chain = chain.then(function () {
                    return Promise.resolve(StorageService.loadReportBranding())['catch'](function () { return null; });
                }).then(function (branding) {
                    var send = [];
                    fileKeys.forEach(function (k) {
                        if (k === 'branding/logo' && syLogoReaddedSince(branding, state.deletes[k].queuedAt)) dropped.push(k);
                        else send.push(k);
                    });
                    if (!send.length) return null;
                    var paths = [];
                    send.forEach(function (k) { paths = paths.concat(SY_FILE_DELETES[k].paths); });
                    return _deleteRemote(paths, token, refresh).then(function () {
                        done = done.concat(send);
                        filesDone = filesDone.concat(send);
                    })['catch'](function (err) {
                        settle(send, err, 'remove the deleted logo from the backup', 'branding', paths.length, true);
                    });
                });
            }
            // Scans and properties: a `deleted.json` tombstone beside the
            // record (R5), which outranks every copy. Then (scans since
            // contract 1.0.2, Q-09; properties since 1.0.3, Q-12) the purge
            // below removes the record's other objects; the entry leaves the queue only when BOTH have landed,
            // so an offline or failed purge is retried by every later pass.
            // The tombstone always goes first: purging without it would let
            // another device upload its copy again (the Lambda refuses, 409).
            var tombstoned = [];
            syChunk(recordKeys, SY_MAX_FILES).forEach(function (chunk) {
                chain = chain.then(function () {
                    var files = chunk.map(function (k) {
                        var slash = k.indexOf('/');
                        var body = JSON.stringify(syTombstoneBody(k.slice(0, slash), k.slice(slash + 1), state.deletes[k].queuedAt));
                        return {
                            key: k,
                            path: k + '/' + SY_TOMBSTONE_FILE,
                            contentType: 'application/json',
                            contentLength: syByteLength(body),   // contract 1.0.1: every PUT is sized
                            body: body
                        };
                    });
                    return _presign('PUT', files, token, refresh).then(function (urls) {
                        var puts = Promise.resolve();
                        files.forEach(function (file) {
                            puts = puts.then(function () {
                                if (!urls[file.path]) throw new Error('no presigned URL for ' + file.path);
                                return _put(urls[file.path], file.body, file.contentType);
                            }).then(function () {
                                if (SY_PURGE_NAMESPACES.indexOf(syNamespaceOf(file.path)) !== -1) tombstoned.push(file.key);
                                else done.push(file.key);
                            })['catch'](function (err) {
                                settle([file.key], err, 'delete ' + file.key, syNamespaceOf(file.path), 1);
                            });
                        });
                        return puts;
                    })['catch'](function (err) {
                        settle(chunk, err, 'delete ' + chunk.length + ' record(s)', syNamespaceOf(chunk[0]), chunk.length);
                    });
                });
            });
            // Purges, for every scan/property whose tombstone landed just now.
            // One namespace per call, so a Lambda that predates 1.0.3 (400 on
            // a property) never holds the scan purges back with it.
            chain = chain.then(function () {
                var purges = Promise.resolve();
                SY_PURGE_NAMESPACES.forEach(function (ns) {
                    var keys = tombstoned.filter(function (k) { return k.indexOf(ns + '/') === 0; });
                    syChunk(keys, SY_PURGE_IDS_PER_CALL).forEach(function (chunk) {
                        purges = purges.then(function () {
                            return _purgeRemote(chunk, token, refresh).then(function () {
                                done = done.concat(chunk);
                            })['catch'](function (err) {
                                settle(chunk, err, 'remove ' + chunk.length + ' deleted ' + ns + ' record(s) from the backup', ns, chunk.length, true);
                            });
                        });
                    });
                });
                return purges;
            });
            return chain.then(function () {
                outcome.deleted = done.length;
                outcome.failed = failed.length;
                return _updateState(function (fresh) {
                    var deletes = Object.assign({}, fresh.deletes);
                    var stamps = Object.assign({}, fresh.stamps);
                    var revs = Object.assign({}, fresh.revs);
                    var retries = Object.assign({}, fresh.retries);
                    done.concat(dropped).forEach(function (k) {
                        delete deletes[k]; delete stamps[k]; delete revs[k]; delete retries[k];
                    });
                    // A landed file delete forgets its record's stamp, so a
                    // logo uploaded again while the delete was in flight is
                    // re-sent by the next mirror or backup, never lost.
                    filesDone.forEach(function (k) { delete stamps[SY_FILE_DELETES[k].record]; });
                    failed.forEach(function (k) {
                        var prev = deletes[k] || { queuedAt: new Date().toISOString(), errorCount: 0 };
                        var count = Math.min((prev.errorCount || 0) + 1, SY_MAX_ERRORS);
                        deletes[k] = { queuedAt: prev.queuedAt, errorCount: count, retryAt: new Date(Date.now() + syBackoffMs(count)).toISOString() };
                    });
                    return { deletes: deletes, stamps: stamps, revs: revs, retries: retries };
                }).then(function () { return outcome; });
            });
        })['catch'](function (err) {
            return { deleted: 0, failed: 0, errors: ['delete queue: ' + ((err && err.message) || 'failed')] };
        });
    }

    /** _queueDeletes — add photo ids to the persisted delete queue (deduped). */
    function _queueDeletes(ids) {
        var clean = (ids || []).filter(function (id) { return syValidPath(SY_PHOTO_NS + '/' + id + '/image.jpg'); });
        if (clean.length === 0) return Promise.resolve(0);
        return _updateState(function (state) {
            var deletes = Object.assign({}, state.deletes);
            var stamps = Object.assign({}, state.stamps);
            clean.forEach(function (id) {
                var k = SY_PHOTO_NS + '/' + id;
                if (!deletes[k]) deletes[k] = { queuedAt: new Date().toISOString(), errorCount: 0, retryAt: null };
                delete stamps[k];   // never "unchanged" again: the id is gone for good
            });
            return { deletes: deletes, stamps: stamps };
        }).then(function () { return clean.length; });
    }

    /**
     * deleteComponentPhoto(photoId, options) — the client half of a photo
     * delete (2026-10-05). Queues `POST /delete` for both of the photo's
     * objects and tries it NOW; signed out, offline, or a failure ⇒ it stays
     * queued and every later backup pass retries it (with backoff). The
     * assessment's tombstone, written by the caller first, is what stops a
     * restore resurrecting the photo in the meantime.
     *
     * Never rejects. Resolves `{ queued: bool, deleted: bool, reason }`.
     * Sync not configured ⇒ nothing to delete remotely: `{ queued: false }`.
     */
    function deleteComponentPhoto(photoId, options) {
        return deleteComponentPhotos([photoId], options);
    }

    /**
     * deleteComponentPhotos(ids, options) — the same for several photos in ONE
     * queue write and ONE flush (a removed room's photos, Q-01 F9). The account
     * is bound BEFORE queueing when signed in, so the ids join the queue of the
     * account that is deleting them (F1). Never rejects.
     */
    function deleteComponentPhotos(photoIds, options) {
        var opts = options || {};
        if (!isConfigured()) return Promise.resolve({ queued: false, deleted: false, reason: 'not configured' });
        var token = null;
        return _boundToken(opts.getToken).then(function (t) {
            token = t;
            return _queueDeletes(photoIds);
        }).then(function (n) {
            if (!n) return { queued: false, deleted: false, reason: 'invalid id' };
            if (!token) return { queued: true, deleted: false, reason: 'signed out' };
            var refresh = function () { return _token(opts.getToken); };
            return _flushDeletes(token, refresh, null, true).then(function (outcome) {
                return { queued: true, deleted: outcome.deleted > 0 && outcome.failed === 0, reason: outcome.errors[0] || null };
            });
        })['catch'](function (err) {
            return { queued: true, deleted: false, reason: (err && err.message) || 'failed' };
        });
    }

    /** pendingDeletes — the photo ids still queued for a server delete (tests, the sheet). */
    function pendingDeletes() {
        return getState().then(function (state) {
            return Object.keys(state.deletes)
                .filter(function (k) { return k.indexOf(SY_PHOTO_NS + '/') === 0; })
                .map(function (k) { return k.slice(SY_PHOTO_NS.length + 1); });
        });
    }

    /** pendingRecordDeletes — `<namespace>/<id>` keys whose tombstone has not landed yet. */
    function pendingRecordDeletes() {
        return getState().then(function (state) {
            return Object.keys(state.deletes).filter(function (k) {
                return k.indexOf(SY_PHOTO_NS + '/') !== 0 && !Object.prototype.hasOwnProperty.call(SY_FILE_DELETES, k);
            });
        });
    }

    /** pendingFileDeletes — single-file delete keys still queued (`branding/logo`; tests, the sheet). */
    function pendingFileDeletes() {
        return getState().then(function (state) {
            return Object.keys(state.deletes).filter(function (k) { return Object.prototype.hasOwnProperty.call(SY_FILE_DELETES, k); });
        });
    }

    /**
     * deleteBrandingLogo({ removedAt, getToken }) — the client half of removing
     * a branding logo (contract 1.0.4, Q-13). Queues `POST /delete` for
     * `branding/current/logo.png` in the same queue as photo deletes and tries
     * it NOW; signed out, offline, CLOUD_SYNC off, or a failure (including a
     * Lambda older than 1.0.4 answering 400) ⇒ it stays queued, and every later
     * backup pass, and the next branding mirror, sends it again (with backoff).
     *
     * `removedAt` is the `updatedAt` the store stamped on the removal: a logo
     * set again later supersedes the queued delete (syLogoReaddedSince). Until
     * the delete lands, the mirrored record's `hasLogo: false` is what keeps
     * restore/pull on any device from bringing the old logo back.
     *
     * Never rejects. Resolves `{ queued, deleted, reason }`.
     */
    function deleteBrandingLogo(options) {
        var opts = options || {};
        if (!isConfigured()) return Promise.resolve({ queued: false, deleted: false, reason: 'not configured' });
        var key = 'branding/logo';
        var removedAt = (typeof opts.removedAt === 'string' && !isNaN(Date.parse(opts.removedAt))) ? opts.removedAt : new Date().toISOString();
        var token = null;
        return _boundToken(opts.getToken).then(function (t) {
            token = t;
            return _updateState(function (state) {
                var deletes = Object.assign({}, state.deletes);
                var prev = deletes[key];
                // A second removal moves the stamp forward (a re-add in between
                // must not cancel it); the retry budget starts over.
                var at = (prev && Date.parse(prev.queuedAt) > Date.parse(removedAt)) ? prev.queuedAt : removedAt;
                deletes[key] = { queuedAt: at, errorCount: 0, retryAt: null };
                return { deletes: deletes };
            });
        }).then(function () {
            if (!token) return { queued: true, deleted: false, reason: 'signed out' };
            if (!syCloudSyncFlagOn()) return { queued: true, deleted: false, reason: 'cloud sync off' };
            var refresh = function () { return _token(opts.getToken); };
            return _flushDeletes(token, refresh, null, true).then(function (outcome) {
                return pendingFileDeletes().then(function (left) {
                    return { queued: true, deleted: left.indexOf(key) === -1, reason: outcome.errors[0] || null };
                });
            });
        })['catch'](function (err) {
            return { queued: true, deleted: false, reason: (err && err.message) || 'failed' };
        });
    }

    /**
     * deleteRecord(namespace, id, options) — the client half of deleting a scan
     * or a property (contract 1.0, R5). Queues a `deleted.json` tombstone in
     * the receipt (`deletes['<ns>/<id>']`) and tries to write it NOW —
     * followed by the purge of its other objects (scans 1.0.2, properties
     * 1.0.3), and the entry
     * is done only when both have landed (`deleted: true`); signed
     * out, offline or failed ⇒ it stays queued, every later backup pass writes
     * it first (with backoff), and until it lands this device's restores skip
     * that id — a pending local delete beats a remote copy.
     *
     * Nothing is queued when sync is not configured, or when this device has
     * never backed up or restored (then no copy of the record can be in the
     * cloud because of this device, and a free user who never syncs writes
     * nothing). Never rejects: `{ queued, deleted, reason }`.
     */
    function deleteRecord(namespace, id, options) {
        var opts = options || {};
        if (!isConfigured()) return Promise.resolve({ queued: false, deleted: false, reason: 'not configured' });
        if (SY_TOMBSTONE_WRITERS.indexOf(namespace) === -1 || !syValidPath(namespace + '/' + id + '/' + SY_TOMBSTONE_FILE)) {
            return Promise.resolve({ queued: false, deleted: false, reason: 'invalid record' });
        }
        var key = namespace + '/' + id;
        var queued = false;
        // Q-01 F1: bind first, so the "synced before" gate below reads THIS
        // account's history, not the previous sign-in's.
        var token = null;
        return _boundToken(opts.getToken).then(function (t) { token = t; return _updateState(function (state) {
            if (!state.lastBackupAt && !state.lastRestoreAt) return {};
            queued = true;
            var deletes = Object.assign({}, state.deletes);
            var stamps = Object.assign({}, state.stamps);
            var retries = Object.assign({}, state.retries);
            if (!deletes[key]) deletes[key] = { queuedAt: new Date().toISOString(), errorCount: 0, retryAt: null };
            delete stamps[key];
            delete retries[key];
            return { deletes: deletes, stamps: stamps, retries: retries };
        }); }).then(function () {
            if (!queued) return { queued: false, deleted: false, reason: 'never synced on this device' };
            if (!token) return { queued: true, deleted: false, reason: 'signed out' };
            // CLOUD_SYNC off: no request now. The entry stays queued (this
            // account HAS cloud copies) and the next backup pass sends it.
            if (!syCloudSyncFlagOn()) return { queued: true, deleted: false, reason: 'cloud sync off' };
            var refresh = function () { return _token(opts.getToken); };
            return _flushDeletes(token, refresh, null, true).then(function (outcome) {
                return pendingRecordDeletes().then(function (left) {
                    return { queued: true, deleted: left.indexOf(key) === -1, reason: outcome.errors[0] || null };
                });
            });
        })['catch'](function (err) {
            return { queued: queued, deleted: false, reason: (err && err.message) || 'failed' };
        });
    }

    /* --- automatic profile / branding mirror (I-08, Cognito plan §4) --- */

    /**
     * mirror(namespace, record, { getToken }) — fire-and-forget write of the
     * ONE profile or branding record to `<namespace>/current/` in the sync
     * bucket. It replaces the Clerk `unsafeMetadata` mirrors (Cognito has no
     * such store, and custom attributes cap at 2,048 chars), and it is not a
     * second storage format: the object is the same envelope a backup pass
     * writes, under the same rules —
     *
     *   • skip-unchanged against the receipt's stamp (no request at all when
     *     this record was already uploaded by this account);
     *   • R3 read-before-overwrite through `_reconcile` (a newer cloud copy is
     *     imported into the store and nothing older is uploaded over it);
     *   • R6 never overwrite an object written by a newer app version;
     *   • R9 a failure keeps the old stamp and earns the jittered retry window,
     *     which the next backup pass honours too.
     *
     * It never touches `lastBackupAt`/`lastResult`/counts (it is not a backup
     * pass), sends no metrics, and never runs inside one: a pass in flight is
     * waited for. Writes are coalesced per namespace (SY_MIRROR_DELAY_MS), so a
     * burst of edits in the branding editor is one upload. Signed out, sync not
     * configured, or the CLOUD_SYNC flag off ⇒ nothing happens. Returns
     * nothing and never throws: it sits on writers the camera path uses.
     */
    function mirror(namespace, record, options) {
        try {
            if (SY_MIRROR_NAMESPACES.indexOf(namespace) === -1 || !record || typeof record !== 'object') return;
            if (!isConfigured() || !syCloudSyncFlagOn()) return;
            var m = _mirrors[namespace] || (_mirrors[namespace] = { record: null, getToken: null, timer: null, running: false });
            m.record = record;
            m.getToken = (options && typeof options.getToken === 'function') ? options.getToken : null;
            if (m.timer) clearTimeout(m.timer);
            m.timer = setTimeout(function () { m.timer = null; _runMirror(namespace); }, SY_MIRROR_DELAY_MS);
        } catch (err) {
            console.warn('[SyncService] ' + namespace + ' mirror not queued (ignored):', err && err.message);
        }
    }

    /** _runMirror — one mirror at a time per namespace; the latest record wins. */
    function _runMirror(namespace) {
        var m = _mirrors[namespace];
        if (!m || m.running || !m.record) return;
        var record = m.record;
        var getToken = m.getToken;
        m.record = null;
        m.running = true;
        var gate = _pass ? _pass.promise : Promise.resolve();
        gate.then(function () {
            return _mirrorOnce(namespace, record, getToken);
        }).then(function () {
            // A logo removed while signed out (or refused by an older Lambda)
            // is still queued (Q-13): the branding mirror sends it too, so it
            // does not wait for a backup pass the user may never run.
            if (namespace !== 'branding') return null;
            return pendingFileDeletes().then(function (keys) {
                if (!keys.length) return null;
                return _boundToken(getToken).then(function (token) {
                    if (!token) return null;
                    return _flushDeletes(token, function () { return _token(getToken); }, null, false);
                });
            });
        })['catch'](function (err) {
            console.warn('[SyncService] ' + namespace + ' mirror failed (ignored):', err && err.message);
        }).then(function () {
            m.running = false;
            if (m.record && !m.timer) _runMirror(namespace);
        });
    }

    function _mirrorOnce(namespace, record, getToken) {
        var refresh = function () { return _token(getToken); };
        return _boundToken(getToken).then(function (token) {
            if (!token) return null;                                   // signed out: nothing to do
            return getState().then(function (state) {
                var data = syDataOf(namespace, record);
                var item = syBackupItems(data).items[0];
                if (!item || syIsBackingOff(state.retries[item.key], Date.now())) return null;
                return sySha256(item.stampSource).then(function (stamp) {
                    if (stamp && state.stamps[item.key] === stamp) return null;   // already in the cloud
                    var rev = (state.revs[item.key] || 0) + 1;
                    var entry = { item: item, stamp: stamp, rev: rev, files: syItemFiles(item, rev), failed: false };
                    var errors = [];
                    var health = syHealthTally();
                    return _reconcile([entry], data, state, getToken, refresh, errors, health, function () {}).then(function () {
                        if (entry.skip === 'same' || entry.failed) return entry;   // stamp it / retry window
                        if (entry.skip) return null;                               // unreadable (R6): leave it be
                        for (var f = 0; f < entry.files.length; f++) {
                            var problem = syPutProblem(entry.files[f]);
                            if (problem) { errors.push(entry.files[f].path + ': ' + problem); return null; }
                        }
                        return _token(getToken).then(function (t) {
                            if (!t) throw new Error('signed out mid-mirror');
                            return _presign('PUT', entry.files, t, refresh);
                        }).then(function (urls) {
                            var puts = Promise.resolve();
                            entry.files.forEach(function (file) {
                                puts = puts.then(function () {
                                    if (!urls[file.path]) throw new Error('no presigned URL for ' + file.path);
                                    return _put(urls[file.path], file.body, file.contentType);
                                });
                            });
                            return puts;
                        }).then(function () { return entry; }, function (err) {
                            entry.failed = true;
                            errors.push(namespace + ': ' + ((err && err.message) || 'upload failed'));
                            return entry;
                        });
                    }).then(function (done) {
                        if (errors.length) console.warn('[SyncService] ' + namespace + ' mirror: ' + errors.join('; '));
                        return done ? _recordMirror(state, done) : null;
                    });
                });
            });
        });
    }

    /** _recordMirror — patch ONE key's stamp/rev/retry, as _finishBackup does per touched key. */
    function _recordMirror(state, entry) {
        return _updateState(function (fresh) {
            if (fresh.accountSub !== state.accountSub) return {};       // account changed mid-mirror (Q-01 F1)
            var key = entry.item.key;
            var stamps = Object.assign({}, fresh.stamps);
            var revs = Object.assign({}, fresh.revs);
            var retries = Object.assign({}, fresh.retries);
            if (entry.failed) {
                var errorCount = Math.min(((retries[key] && retries[key].errorCount) || 0) + 1, SY_MAX_ERRORS);
                retries[key] = { errorCount: errorCount, retryAt: new Date(Date.now() + syBackoffMs(errorCount)).toISOString() };
            } else {
                if (entry.stamp) stamps[key] = entry.stamp;
                revs[key] = entry.rev;
                delete retries[key];
            }
            return { stamps: syRecencyOrder(stamps, [key]), revs: syRecencyOrder(revs, [key]), retries: retries };
        });
    }

    /**
     * pull(namespace, { getToken }) — the adoption half of the old Clerk
     * mirror: fetch `<namespace>/current/` and offer it to the store's
     * importer, which applies R3 (strictly newer wins, a tie keeps local). Only
     * profile/branding; no receipt or `lastRestoreAt` change (it is not a
     * restore pass). Resolves true when the store took the cloud copy; never
     * rejects.
     */
    function pull(namespace, options) {
        var opts = options || {};
        try {
            if (SY_MIRROR_NAMESPACES.indexOf(namespace) === -1 || !isConfigured() || !syCloudSyncFlagOn()) return Promise.resolve(false);
        } catch (err) {
            return Promise.resolve(false);
        }
        var refresh = function () { return _token(opts.getToken); };
        // _token, not _boundToken: a pull reads no receipt, so it has no reason to bind (or reset) one.
        return _token(opts.getToken).then(function (token) {
            if (!token) return false;
            return _list(token, refresh).then(function (listing) {
                var listed = syListingIndex(listing)[namespace + '/current'];
                var wanted = listed ? _wantedFor(listed, false) : [];
                if (!wanted.length) return false;
                var errors = [];
                return _download(wanted, opts.getToken, refresh, errors, syHealthTally(), function () {}, 'Fetching').then(function (fetched) {
                    var assembled = syAssemble(wanted, fetched);
                    if (!assembled.records[namespace]) return false;
                    var offer = {};
                    offer[namespace] = assembled.records[namespace];
                    var counts = _offer(offer, null, errors, null);
                    if (errors.length) console.warn('[SyncService] ' + namespace + ' pull: ' + errors.join('; '));
                    return (counts.added + counts.replaced) > 0;
                });
            });
        })['catch'](function (err) {
            console.warn('[SyncService] ' + namespace + ' pull failed (ignored):', err && err.message);
            return false;
        });
    }

    /**
     * _uploadPhoto — one pending component-photo item: read its image from
     * IndexedDB, check it against the contract (JPEG, ≤ 2 MiB), presign both
     * objects with their exact lengths, PUT them. One photo at a time keeps
     * at most one image in memory. Resolves `'ok' | 'missing' | 'failed'`.
     * `missing` (this device has only the metadata, e.g. it restored the visit
     * but not yet the picture) is not an error and records no stamp.
     */
    function _uploadPhoto(entry, getToken, refresh, errors, health) {
        var item = entry.item;
        return StorageService.loadComponentPhoto(item.id)['catch'](function () { return null; }).then(function (rec) {
            if (!rec || typeof rec.dataURL !== 'string') return 'missing';
            var image = syDataUrlToBlob(rec.dataURL);
            var problem = null;
            if (!image || image.contentType !== 'image/jpeg') problem = 'image is not a JPEG';
            else if (image.blob.size > SY_PHOTO_MAX_BYTES) problem = 'image is larger than 2 MB';
            item.record = syPhotoRecord(item.photo, rec.exif);
            item.images = image ? [{ name: 'image.jpg', blob: image.blob, contentType: 'image/jpeg' }] : [];
            var files = syItemFiles(item, entry.rev);
            if (!problem && files[0].contentLength > SY_PHOTO_META_MAX_BYTES) problem = 'photo metadata is larger than 64 KB';
            if (problem) {
                errors.push(item.docPath + ': ' + problem);
                health.add('validation', SY_PHOTO_NS, files.length);
                return 'failed';
            }
            return _token(getToken).then(function (token) {
                if (!token) throw new Error('signed out mid-backup');
                return _presign('PUT', files, token, refresh);
            }).then(function (urls) {
                var puts = Promise.resolve();
                files.forEach(function (file) {
                    puts = puts.then(function () {
                        var url = urls[file.path];
                        if (!url) throw new Error('no presigned URL for ' + file.path);
                        return _put(url, file.body, file.contentType);
                    });
                });
                return puts.then(function () { return files.length; });
            }).then(function (n) {
                entry.uploadedFiles = n;
                return 'ok';
            })['catch'](function (err) {
                errors.push(item.docPath + ': ' + ((err && err.message) || 'upload failed'));
                health.add(syErrorClass(err, err && err.status && /^presign/.test(err.message) ? 'presign' : 'put'), SY_PHOTO_NS, files.length);
                return 'failed';
            });
        });
    }

    /**
     * _restorePhotos — phase two of a restore (2026-10-05). Runs after the
     * JSON records are downloaded and BEFORE they are merged, so an attachment
     * never appears without its picture when the picture is available.
     *
     * Downloads a listed photo only when (a) a local or restored assessment
     * still names it in `attachments[]`, (b) no assessment tombstones it, and
     * (c) this device does not already have its image (images are immutable per
     * id). A listed photo that IS tombstoned is queued for a server delete —
     * the cleanup a device that missed the original delete owes the bucket.
     * Resolves `{ restored, files }`; failures go to `errors`/`health`.
     */
    function _restorePhotos(photoListing, remoteAssessments, errors, health, getToken, refresh, progress) {
        if (!photoListing.length) return Promise.resolve({ restored: 0, files: 0 });
        return Promise.resolve(StorageService.loadAssessments())['catch'](function () { return []; }).then(function (local) {
            var remote = remoteAssessments || [];
            var index = syPhotoIndex((local || []).concat(remote));
            var named = {};
            index.photos.forEach(function (photo) { named[photo.id] = photo; });

            var tombstoned = [];
            var candidates = [];
            photoListing.forEach(function (entry) {
                if (index.deleted[entry.id] || index.roomDeleted[entry.id]) tombstoned.push(entry.id);   // Q-01 F9: a removed room's photos too
                else if (named[entry.id] && entry.names['image.jpg']) candidates.push(entry);
            });

            // Queue AND send the cleanup now (best effort, no health report:
            // the restore's own outcome is about reading). Failures stay queued.
            var queued = tombstoned.length
                ? _queueDeletes(tombstoned).then(function () { return _token(getToken); }).then(function (token) {
                    return token ? _flushDeletes(token, refresh, null, true) : null;
                })
                : Promise.resolve(null);
            return queued.then(function () {
                return Promise.all(candidates.map(function (c) {
                    return StorageService.loadComponentPhoto(c.id)['catch'](function () { return null; });
                }));
            }).then(function (existing) {
                var need = candidates.filter(function (c, i) { return !(existing[i] && typeof existing[i].dataURL === 'string'); });
                var restored = 0;
                var files = 0;
                var chain = Promise.resolve();
                need.forEach(function (entry, n) {
                    chain = chain.then(function () {
                        progress('Downloading photo ' + (n + 1) + ' of ' + need.length + '…');
                        var paths = [{ path: entry.names['image.jpg'] }];
                        if (entry.names['metadata.json']) paths.push({ path: entry.names['metadata.json'] });
                        return _token(getToken).then(function (token) {
                            if (!token) throw new Error('signed out mid-restore');
                            return _presign('GET', paths, token, refresh);
                        }).then(function (urls) {
                            var imageUrl = urls[entry.names['image.jpg']];
                            if (!imageUrl) throw new Error('no presigned URL for ' + entry.names['image.jpg']);
                            var metaUrl = entry.names['metadata.json'] ? urls[entry.names['metadata.json']] : null;
                            var metaP = metaUrl
                                ? _getBlob(metaUrl).then(function (b) { return b.text(); }).then(function (t) { return syUnwrap(JSON.parse(t)); })['catch'](function () { return null; })
                                : Promise.resolve(null);
                            return Promise.all([_getBlob(imageUrl).then(syBlobToDataUrl), metaP]);
                        }).then(function (got) {
                            var dataURL = got[0];
                            if (typeof dataURL !== 'string' || dataURL.indexOf('data:image/') !== 0) {
                                // S3 hands back the stored Content-Type; anything else is not a photo.
                                var bad = new Error('downloaded image is not an image');
                                bad.name = 'SyntaxError';
                                throw bad;
                            }
                            var meta = (got[1] && typeof got[1] === 'object') ? got[1] : {};
                            var owner = named[entry.id];
                            var thumbP = (typeof ThumbnailService !== 'undefined' && ThumbnailService.generate)
                                ? Promise.resolve(ThumbnailService.generate(dataURL, 400, 0.75))['catch'](function () { return ''; })
                                : Promise.resolve('');
                            return thumbP.then(function (thumb) {
                                return StorageService.saveComponentPhoto(entry.id, {
                                    v: 1, id: entry.id,
                                    assessmentId: owner.assessmentId, roomId: owner.roomId, componentKey: owner.componentKey,
                                    createdAt: owner.createdAt || meta.createdAt || null,
                                    mime: 'image/jpeg', dataURL: dataURL, thumbnail: thumb || '',
                                    exif: syJsonSafe(meta.exif)
                                });
                            }).then(function () {
                                restored += 1;
                                files += paths.length;
                            });
                        })['catch'](function (err) {
                            errors.push(entry.names['image.jpg'] + ': ' + ((err && err.message) || 'download failed'));
                            health.add(syErrorClass(err, 'get'), SY_PHOTO_NS, paths.length);
                        });
                    });
                });
                return chain.then(function () {
                    if (restored > 0) {
                        try { window.dispatchEvent(new Event(SY_PHOTOS_CHANGED_EVENT)); } catch (err) { /* no listener is fine */ }
                    }
                    return { restored: restored, files: files };
                });
            });
        })['catch'](function (err) {
            errors.push('component-photos: ' + ((err && err.message) || 'restore failed'));
            health.add('unknown', SY_PHOTO_NS, 0);
            return { restored: 0, files: 0 };
        });
    }

    /* --- backup ----------------------------------------------------- */

    /**
     * backup — upload every local record that has CHANGED. Options:
     *   getToken (func)  — `useClerkAuth().getToken`; falls back to window.Clerk
     *   onProgress(text) — progress line for the sheet
     *
     * Three things decide whether an object is sent (A1, and the Swift client's
     * `stamps` dictionary before it):
     *   • its SHA-256 differs from the one recorded for it last time, or there
     *     is no recorded stamp (`unchanged` counts the rest);
     *   • it is not inside a retry window from a previous failure (`deferred`);
     *   • its id passes the Lambda's own path regex (`skipped`).
     *
     * Per-ITEM fault isolation: a failed file fails only its own item, which is
     * then given a jittered `retryAt` so the next run spends its round trips on
     * everything else first. Nothing here ever rejects.
     */
    function backup(options) {
        var opts = options || {};
        var progress = (typeof opts.onProgress === 'function') ? opts.onProgress : function () {};

        if (!isConfigured()) {
            return Promise.resolve(syResult({ reason: 'Cloud backup is not configured yet.' }));
        }
        if (_pass && _pass.kind === 'backup') return _pass.promise;    // single-flight
        if (_pass) return Promise.resolve(syResult({ reason: 'A sync is already running.' }));

        var refresh = function () { return _token(opts.getToken); };

        var run = _boundToken(opts.getToken).then(function (token) {   // Q-01 F1: receipt bound to this account first
            if (!token) {
                return syResult({ reason: 'Sign in to back up your data.' });
            }
            var errors = [];
            var health = syHealthTally();
            var deleted = 0;
            progress('Collecting your records…');
            // Queued photo deletes go first (2026-10-05): a delete that has
            // waited for a connection should not wait behind a long upload.
            return _flushDeletes(token, refresh, health, false).then(function (del) {
                deleted = del.deleted;
                errors = errors.concat(del.errors);
                return Promise.all([
                    StorageService.loadScans(),
                    StorageService.loadProperties(),
                    StorageService.loadAssessments(),
                    StorageService.loadUserProfile(),
                    StorageService.loadReportBranding(),
                    getState()
                ]);
            }).then(function (loaded) {
                // P-08: only the scans THIS account owns are backed up. An
                // unlinked scan waits for the person's answer (SyncSheet asks
                // first); another account's scan and a device-only scan are
                // never uploaded here, whatever the caller.
                var owned = syPartitionScans(loaded[0] || [], _currentSub(token));
                var data = {
                    scans: owned.mine, properties: loaded[1] || [], assessments: loaded[2] || [],
                    profile: loaded[3], branding: loaded[4],
                    photos: syPhotoIndex(loaded[2] || []).photos,
                    unlinkedScans: owned.unlinked.length
                };
                var state = loaded[5];
                var plan = syBackupItems(data);
                var s;
                for (s = 0; s < plan.skipped.length; s++) {
                    errors.push(plan.skipped[s].path + ': ' + plan.skipped[s].reason);
                    health.add('validation', syNamespaceOf(plan.skipped[s].path), 1);
                }

                var now = Date.now();
                var deferred = 0;
                var ready = [];
                for (s = 0; s < plan.items.length; s++) {
                    if (syIsBackingOff(state.retries[plan.items[s].key], now)) { deferred += 1; continue; }
                    ready.push(plan.items[s]);
                }

                // Stamp every candidate before deciding anything: the digests are
                // independent, so they run in parallel rather than one per item.
                return Promise.all(ready.map(function (item) { return sySha256(item.stampSource); }))
                    .then(function (stamps) {
                        var pending = [];
                        var photoPending = [];
                        var unchanged = 0;
                        var i;
                        for (i = 0; i < ready.length; i++) {
                            var stamp = stamps[i];
                            if (stamp && state.stamps[ready[i].key] === stamp) { unchanged += 1; continue; }
                            var rev = (state.revs[ready[i].key] || 0) + 1;
                            if (ready[i].photo) {
                                // Files built in _uploadPhoto, once the image is read.
                                photoPending.push({ item: ready[i], stamp: stamp, rev: rev, files: [], failed: false });
                                continue;
                            }
                            pending.push({ item: ready[i], stamp: stamp, rev: rev, files: syItemFiles(ready[i], rev), failed: false });
                        }

                        var uploaded = 0;

                        // Component photos, one at a time, after the records
                        // that name them (2026-10-05).
                        var uploadPhotos = function () {
                            var touched = [];
                            var photoChain = Promise.resolve();
                            photoPending.forEach(function (entry, n) {
                                photoChain = photoChain.then(function () {
                                    progress('Backing up photo ' + (n + 1) + ' of ' + photoPending.length + '…');
                                    return _uploadPhoto(entry, opts.getToken, refresh, errors, health).then(function (result) {
                                        if (result === 'missing') return;
                                        entry.failed = (result !== 'ok');
                                        if (result === 'ok') uploaded += entry.uploadedFiles || 0;
                                        touched.push(entry);
                                    });
                                });
                            });
                            return photoChain.then(function () { return touched; });
                        };

                        if (pending.length === 0) {
                            return uploadPhotos().then(function (photoTouched) {
                                return _finishBackup(data, state, {
                                    uploaded: uploaded, unchanged: unchanged, deferred: deferred, deleted: deleted,
                                    skipped: plan.skipped.length, errors: errors, touched: photoTouched, health: health,
                                    liveKeys: plan.items.map(function (it) { return it.key; })
                                }, opts.getToken);
                            });
                        }

                        // R3/R6 — never overwrite a cloud copy this device has
                        // not read: every changed record that already exists in
                        // the cloud is read and merged first (_reconcile).
                        return _reconcile(pending, data, state, opts.getToken, refresh, errors, health, progress).then(function (rec) {
                        // Contract 1.0.1: a file the Lambda would refuse for its
                        // size or type is reported here, not sent. The item gets
                        // no stamp and no retry window, so it is named again on
                        // every pass until the record changes.
                        pending.forEach(function (e) {
                            if (e.skip || e.failed) return;
                            for (var pf = 0; pf < e.files.length; pf++) {
                                var problem = syPutProblem(e.files[pf]);
                                if (!problem) continue;
                                e.skip = 'invalid';
                                errors.push(e.files[pf].path + ': ' + problem);
                                health.add('validation', syNamespaceOf(e.files[pf].path), 1);
                                break;
                            }
                        });
                        var toUpload = pending.filter(function (e) { return !e.skip && !e.failed; });

                        // Flatten to files, remembering which item each belongs to,
                        // so a failure marks the item and not the whole batch.
                        var files = [];
                        var owner = {};
                        for (i = 0; i < toUpload.length; i++) {
                            for (var f = 0; f < toUpload[i].files.length; f++) {
                                files.push(toUpload[i].files[f]);
                                owner[toUpload[i].files[f].path] = toUpload[i];
                            }
                        }
                        // Touched = uploaded, already identical in the cloud
                        // (stamped, nothing sent), or failed to reconcile (retry
                        // window). Unreadable/tombstoned records get no stamp.
                        var touchedRecords = pending.filter(function (e) { return !e.skip || e.skip === 'same'; });
                        // A record the cloud already holds byte-for-byte is
                        // "already up to date", like a stamp match.
                        unchanged += pending.filter(function (e) { return e.skip === 'same'; }).length;

                        var batches = syChunk(files, SY_MAX_FILES);
                        var chain = Promise.resolve();

                        batches.forEach(function (batch, batchIndex) {
                            chain = chain.then(function () {
                                progress('Backing up ' + (batchIndex + 1) + ' of ' + batches.length + '…');
                                // A fresh token per batch: session JWTs are short-lived
                                // and a slow link outlives one (CLIENT-CONTRACT.md).
                                return _token(opts.getToken).then(function (batchToken) {
                                    if (!batchToken) throw new Error('signed out mid-backup');
                                    return _presign('PUT', batch, batchToken, refresh);
                                }).then(function (urls) {
                                    var puts = Promise.resolve();
                                    batch.forEach(function (file) {
                                        puts = puts.then(function () {
                                            var url = urls[file.path];
                                            if (!url) throw new Error('no presigned URL for ' + file.path);
                                            return _put(url, file.body, file.contentType).then(function () { uploaded += 1; });
                                        })['catch'](function (err) {
                                            owner[file.path].failed = true;
                                            errors.push(file.path + ': ' + ((err && err.message) || 'upload failed'));
                                            health.add(syErrorClass(err, 'put'), syNamespaceOf(file.path), 1);
                                        });
                                    });
                                    return puts;
                                })['catch'](function (err) {
                                    // A whole batch failed (presign, or no token):
                                    // every item it carried keeps its old stamp and
                                    // earns a retry window.
                                    var batchClass = syErrorClass(err, 'presign');
                                    batch.forEach(function (file) {
                                        owner[file.path].failed = true;
                                        health.add(batchClass, syNamespaceOf(file.path), 1);
                                    });
                                    errors.push('batch ' + (batchIndex + 1) + ': ' + ((err && err.message) || 'failed'));
                                });
                            });
                        });

                        return chain.then(uploadPhotos).then(function (photoTouched) {
                            return _finishBackup(data, state, {
                                uploaded: uploaded, unchanged: unchanged, deferred: deferred, deleted: deleted,
                                skipped: plan.skipped.length, errors: errors, touched: touchedRecords.concat(photoTouched), health: health,
                                liveKeys: plan.items.map(function (it) { return it.key; }),
                                merged: rec.merged, removed: rec.removed, unreadable: rec.unreadable
                            }, opts.getToken);
                        });
                        });
                    });
            });
        })['catch'](function (err) {
            console.error('[SyncService] backup failed:', err);
            _reportHealth([{ name: 'sync_upload_failed', props: { errorClass: syErrorClass(err, 'local'), namespace: 'none', files: 0 } }], opts.getToken);
            return syResult({ reason: 'Backup did not finish. Check your connection and try again.', errors: [(err && err.message) || 'unknown error'] });
        }).then(function (result) {
            _pass = null;
            progress('');
            return result;
        });

        _pass = { kind: 'backup', promise: run };
        return run;
    }

    /**
     * _finishBackup — record what happened, then answer.
     *
     * A succeeded item takes its new stamp and revision and drops any retry
     * entry; a failed one keeps the OLD stamp (so the next run tries it again)
     * and gets a jittered `retryAt`, so a single poisoned object cannot spend
     * the whole of every future pass.
     */
    function _finishBackup(data, state, outcome, tokenOverride) {
        var stamps = Object.assign({}, state.stamps);
        var revs = Object.assign({}, state.revs);
        var retries = Object.assign({}, state.retries);
        var now = Date.now();
        var touchedKeys = {};

        for (var i = 0; i < outcome.touched.length; i++) {
            var entry = outcome.touched[i];
            var key = entry.item.key;
            touchedKeys[key] = true;
            if (entry.failed) {
                var errorCount = Math.min(((retries[key] && retries[key].errorCount) || 0) + 1, SY_MAX_ERRORS);
                retries[key] = { errorCount: errorCount, retryAt: new Date(now + syBackoffMs(errorCount)).toISOString() };
            } else {
                if (entry.stamp) stamps[key] = entry.stamp;
                revs[key] = entry.rev;
                delete retries[key];
            }
        }

        var counts = {
            scans: data.scans.length,
            properties: data.properties.length,
            assessments: data.assessments.length,
            profile: data.profile ? 1 : 0,
            branding: data.branding ? 1 : 0,
            'component-photos': (data.photos || []).length
        };
        var nothingToDo = outcome.uploaded === 0 && !outcome.deleted && outcome.errors.length === 0;
        if (window.MetricsService && outcome.errors.length === 0) MetricsService.track('backup_completed', { scans: counts.scans, assessments: counts.assessments }); // product metrics §2 — two counts, no paths
        // Cloud Saves health: a CLEAN pass reports ok (even "nothing to do" —
        // it proves the round trip works); any failure reports only the
        // failure groups, so a partial pass reads as "still failing".
        if (outcome.health && outcome.health.size() > 0) {
            _reportHealth(outcome.health.events('sync_upload_failed'), tokenOverride);
        } else if (outcome.errors.length === 0) {
            _reportHealth([{ name: 'sync_upload_ok', props: { files: outcome.uploaded, unchanged: outcome.unchanged, deferred: outcome.deferred } }], tokenOverride);
        }
        // Patch only the keys this pass TOUCHED onto the state as it is NOW:
        // a delete queued (and its stamp dropped) mid-pass must survive.
        return _updateState(function (fresh) {
            // Q-01 F1: the account changed while this pass ran — its receipts
            // belong to the previous account's prefix; record nothing.
            if (fresh.accountSub !== state.accountSub) return {};
            var nextStamps = Object.assign({}, fresh.stamps);
            var nextRevs = Object.assign({}, fresh.revs);
            var nextRetries = Object.assign({}, fresh.retries);
            for (var k in touchedKeys) {
                if (!Object.prototype.hasOwnProperty.call(touchedKeys, k)) continue;
                if (fresh.deletes[k]) continue;   // deleted while uploading: never stamp it
                if (stamps[k]) nextStamps[k] = stamps[k]; else delete nextStamps[k];
                if (revs[k]) nextRevs[k] = revs[k]; else delete nextRevs[k];
                if (retries[k]) nextRetries[k] = retries[k]; else delete nextRetries[k];
            }
            var patch = {
                lastBackupAt: new Date().toISOString(),
                lastResult: outcome.errors.length === 0 ? 'ok' : (outcome.uploaded > 0 ? 'partial' : 'failed'),
                errorCount: outcome.errors.length,
                counts: counts,
                // Q-01 F6: keys confirmed live this pass go last = most recent.
                stamps: syRecencyOrder(nextStamps, outcome.liveKeys),
                revs: syRecencyOrder(nextRevs, outcome.liveKeys),
                retries: nextRetries
            };
            // Q-01 F1: a clean pass under the new account retires the reset note.
            if (outcome.errors.length === 0) patch.accountReset = null;
            return patch;
        }).then(function () {
            var reason = null;
            var quotaFull = outcome.errors.some(function (e) { return /QUOTA_EXCEEDED/.test(String(e)); });
            if (quotaFull) {
                // 403 QUOTA_EXCEEDED (contract 1.0.1): not retried, not "sign in again".
                reason = 'Your cloud backup is full, so new and changed items were not uploaded. Everything already backed up is safe.';
            } else if (outcome.errors.length > 0) {
                reason = 'Some files did not finish. Try again to retry the rest.';
            } else if (nothingToDo && !outcome.merged) {
                reason = (outcome.unchanged > 0)
                    ? 'Everything is already backed up.'
                    : 'Nothing to back up yet.';
            }
            if (outcome.unreadable > 0) {
                reason = (reason ? reason + ' ' : '') + outcome.unreadable + ' item' + (outcome.unreadable === 1 ? ' in the cloud was' : 's in the cloud were') +
                    ' saved by a newer version of the app, so ' + (outcome.unreadable === 1 ? 'it was' : 'they were') + ' not overwritten — update the app.';
            }
            return syResult({
                ok: outcome.errors.length === 0,
                uploaded: outcome.uploaded,
                unchanged: outcome.unchanged,
                deferred: outcome.deferred,
                deleted: outcome.deleted || 0,
                skipped: outcome.skipped,
                merged: outcome.merged || 0,
                removed: outcome.removed || 0,
                unreadable: outcome.unreadable || 0,
                // P-08: scans left out because no account has claimed them yet.
                unlinked: data.unlinkedScans || 0,
                errors: outcome.errors,
                reason: reason
            });
        });
    }

    /**
     * _reconcile — read-before-overwrite for a backup pass (contract 1.0, R3).
     *
     * Skip-unchanged decides WHAT this device wants to upload; this decides
     * whether it MAY. For every pending record that already exists in the
     * cloud (one `GET /list`, then one GET per such record — JSON only, plus
     * `logo.png` for branding):
     *
     *   • a `deleted.json` beside it (R5) ⇒ not uploaded; the local copy is
     *     removed through the importer (a delete is final);
     *   • written by a newer app version (R6) ⇒ not uploaded, not imported;
     *   • otherwise the R3/R4 merge decides: the remote copy (or its part of a
     *     field-level merge) is imported into the live store, and what is
     *     uploaded is the MERGED record — or nothing, when the cloud already
     *     holds exactly that.
     *
     * The cloud copy cannot be read (list or GET failed) ⇒ that record is NOT
     * uploaded this pass and gets a retry window: never overwrite blind.
     * Mutates the `pending` entries (`skip`, `failed`, `item`, `stamp`,
     * `files`) and resolves `{ merged, removed, unreadable }`; never rejects.
     *
     * Known window: another device writing between this GET and this PUT is
     * overwritten (seconds; S3 conditional writes would close it — open
     * question in CLIENT-CONTRACT.md).
     */
    function _reconcile(pending, data, state, getToken, refresh, errors, health, progress) {
        var none = { merged: 0, removed: 0, unreadable: 0 };
        return _token(getToken).then(function (token) {
            if (!token) throw new Error('signed out mid-backup');
            progress('Checking the cloud copies…');
            return _list(token, refresh);
        }).then(function (listing) {
            var index = syListingIndex(listing);
            var wanted = [];
            var byKey = {};
            var tombstones = { scans: [], properties: [], assessments: [] };
            pending.forEach(function (entry) {
                var listed = index[entry.item.key];
                if (!listed) return;                              // not in the cloud: nothing to overwrite
                if (listed.names[SY_TOMBSTONE_FILE] && SY_TOMBSTONE_READERS.indexOf(entry.item.namespace) !== -1) {
                    entry.skip = 'tombstoned';
                    tombstones[entry.item.namespace].push(entry.item.id);
                    return;
                }
                var w = _wantedFor(listed, false);
                if (!w.length) return;
                wanted = wanted.concat(w);
                byKey[entry.item.key] = entry;
            });
            var tombCount = tombstones.scans.length + tombstones.properties.length + tombstones.assessments.length;
            if (!wanted.length && !tombCount) return none;
            return _download(wanted, getToken, refresh, errors, health, progress, 'Checking the cloud copy').then(function (fetched) {
                var assembled = syAssemble(wanted, fetched);
                var imports = { scans: [], properties: [], assessments: [], profile: null, branding: null };
                var remoteByKey = {};
                ['scans', 'properties', 'assessments'].forEach(function (ns) {
                    assembled.records[ns].forEach(function (r) { remoteByKey[ns + '/' + r.id] = r; });
                });
                if (assembled.records.profile) remoteByKey['profile/current'] = assembled.records.profile;
                if (assembled.records.branding) remoteByKey['branding/current'] = assembled.records.branding;

                var merged = 0;
                var rebuilt = [];
                Object.keys(byKey).forEach(function (key) {
                    var entry = byKey[key];
                    var ns = entry.item.namespace;
                    var st = assembled.status[key];
                    if (st === 'missing') { entry.failed = true; return; }        // could not read it: do not overwrite it
                    if (st === 'unsupported') { entry.skip = 'unreadable'; return; }
                    if (st !== 'record') return;                                   // garbage in the cloud: our copy replaces it
                    var remote = remoteByKey[key];
                    var local = syLocalRecord(data, ns, entry.item.id);
                    var result = syMergeRecord(ns, local, remote);
                    var res = syComparable(ns, result);
                    var loc = syComparable(ns, local);
                    if (res !== loc) {
                        merged += 1;
                        if (ns === 'profile' || ns === 'branding') imports[ns] = remote; else imports[ns].push(remote);
                        rebuilt.push({ entry: entry, record: result });
                    }
                    if (res === syComparable(ns, remote)) entry.skip = 'same';     // the cloud already holds the result
                });

                var counts = _offer(imports, tombstones, errors, health);
                return Promise.all(rebuilt.map(function (r) {
                    var item = syBackupItems(syDataOf(r.entry.item.namespace, r.record)).items[0];
                    if (!item) return null;
                    return sySha256(item.stampSource).then(function (stamp) {
                        r.entry.item = item;
                        r.entry.stamp = stamp;
                        r.entry.files = syItemFiles(item, r.entry.rev);
                    });
                })).then(function () {
                    return { merged: merged, removed: counts.removed, unreadable: assembled.unreadable };
                });
            });
        })['catch'](function (err) {
            errors.push('checking the cloud copies: ' + ((err && err.message) || 'failed'));
            health.add(syErrorClass(err, 'list'), 'none', 0);
            pending.forEach(function (e) { e.failed = true; });
            return none;
        });
    }

    /* --- shared download + import plumbing (contract 1.0) ------------- */

    /**
     * _download — presign GET + fetch every entry of `wanted` in batches of 32,
     * with per-ENTRY fault isolation: one object that will not come down is
     * named in `errors` and tallied in `health`, and everything else still
     * arrives. Resolves the `path → parsed JSON | data URL` map; never rejects.
     */
    function _download(wanted, getToken, refresh, errors, health, progress, label) {
        var fetched = {};
        var batches = syChunk(wanted, SY_MAX_FILES);
        var chain = Promise.resolve();
        batches.forEach(function (batch, batchIndex) {
            chain = chain.then(function () {
                progress(label + ' ' + (batchIndex + 1) + ' of ' + batches.length + '…');
                return _token(getToken).then(function (batchToken) {
                    if (!batchToken) throw new Error('signed out mid-restore');
                    return _presign('GET', batch, batchToken, refresh);
                }).then(function (urls) {
                    var gets = Promise.resolve();
                    batch.forEach(function (entry) {
                        gets = gets.then(function () {
                            var url = urls[entry.path];
                            if (!url) throw new Error('no presigned URL for ' + entry.path);
                            return _getBlob(url).then(function (blob) {
                                if (entry.kind === 'json') {
                                    return blob.text().then(function (text) {
                                        fetched[entry.path] = JSON.parse(text);
                                    });
                                }
                                return syBlobToDataUrl(blob).then(function (dataUrl) {
                                    fetched[entry.path] = dataUrl;
                                });
                            });
                        })['catch'](function (err) {
                            errors.push(entry.path + ': ' + ((err && err.message) || 'download failed'));
                            health.add(syErrorClass(err, 'get'), entry.namespace, 1);
                        });
                    });
                    return gets;
                })['catch'](function (err) {
                    var batchClass = syErrorClass(err, 'presign');
                    batch.forEach(function (entry) { health.add(batchClass, entry.namespace, 1); });
                    errors.push('batch ' + (batchIndex + 1) + ': ' + ((err && err.message) || 'failed'));
                });
            });
        });
        return chain.then(function () { return fetched; });
    }

    /** _wantedFor — the objects to download for one listed record item. */
    function _wantedFor(entry, withImages) {
        var out = [];
        var doc = entry.names[syDocName(entry.namespace)];
        if (!doc) return out;
        out.push({ namespace: entry.namespace, id: entry.id, path: doc, kind: 'json' });
        if (withImages && entry.namespace === 'scans' && entry.names['thumbnail.jpg']) {
            out.push({ namespace: 'scans', id: entry.id, path: entry.names['thumbnail.jpg'], kind: 'thumbnail' });
        }
        if (entry.namespace === 'branding' && entry.names['logo.png']) {
            out.push({ namespace: 'branding', id: entry.id, path: entry.names['logo.png'], kind: 'logo' });
        }
        return out;
    }

    /**
     * _offer — hand each namespace's remote records (and the ids the cloud
     * tombstones, R5) to its store's importer, in dependency order: a property
     * must exist before the scans and assessments that name it.
     *
     * A namespace with no importer registered is SKIPPED and named in `errors`:
     * writing it to IndexedDB behind the store's back is the silent data loss
     * review F1 removed. Resolves the summed counts.
     */
    function _offer(records, tombstones, errors, health) {
        var sum = { added: 0, replaced: 0, merged: 0, kept: 0, removed: 0 };
        var tombs = tombstones || {};
        function offer(namespace, recs) {
            var gone = tombs[namespace] || [];
            var empty = !recs || (recs.push && recs.length === 0);
            if (empty && gone.length === 0) return;
            var importer = _importers[namespace];
            var recordCount = (recs && recs.push) ? recs.length : (recs ? 1 : 0);
            if (!importer) {
                errors.push(namespace + ': nothing is listening for restored records (the store is not mounted)');
                if (health) health.add('import', namespace, recordCount + gone.length);
                return;
            }
            var counts = null;
            try {
                counts = importer(empty ? (recs && recs.push ? [] : null) : recs, gone, { sub: _activeSub });
            } catch (err) {
                errors.push(namespace + ': import failed — ' + ((err && err.message) || 'unknown error'));
                if (health) health.add('import', namespace, recordCount);
                return;
            }
            if (!counts) {
                errors.push(namespace + ': the store could not read its own records, so nothing was merged');
                if (health) health.add('import', namespace, recordCount);
                return;
            }
            sum.added += counts.added || 0;
            sum.replaced += counts.replaced || 0;
            sum.merged += counts.merged || 0;
            sum.kept += counts.kept || 0;
            sum.removed += counts.removed || 0;
        }
        offer('properties', records.properties);
        offer('scans', records.scans);
        offer('assessments', records.assessments);
        if (records.profile) offer('profile', records.profile);
        if (records.branding) offer('branding', records.branding);
        return sum;
    }

    /* --- restore ---------------------------------------------------- */

    /**
     * restore({ merge: true }) — pull everything in the account and merge it in
     * by id under the contract-1.0 rules (CLIENT-CONTRACT.md "Restore &
     * conflict rules"). `merge:false` is deliberately NOT supported: a
     * replace-everything restore is a destructive operation that needs its own
     * confirmation UI and its own decision.
     *
     * Restored records go through the STORES (review F1): each provider
     * registers an importer that merges into LIVE state and lets the ordinary
     * debounce persist the result, so an edit made while the download was in
     * flight is merged on top of, never behind (R7, review F4).
     *
     * Order (R8): list → skip what the cloud tombstones and what this device has
     * a pending delete for → download records → download component photos the
     * merged visits still name (BEFORE the merge, so an attachment never shows
     * without its picture) → offer to the importers, properties first.
     *
     * Resumable (R9): every object that arrived is merged even when others
     * failed; the merge is idempotent, so "Restore" again fetches the rest and
     * changes nothing that already landed.
     */
    function restore(options) {
        var opts = options || {};
        var progress = (typeof opts.onProgress === 'function') ? opts.onProgress : function () {};

        if (!isConfigured()) {
            return Promise.resolve(syResult({ reason: 'Cloud backup is not configured yet.' }));
        }
        if (opts.merge === false) {
            return Promise.resolve(syResult({ reason: 'Only a merging restore is supported.' }));
        }
        if (_pass && _pass.kind === 'restore') return _pass.promise;   // single-flight
        if (_pass) return Promise.resolve(syResult({ reason: 'A sync is already running.' }));

        var refresh = function () { return _token(opts.getToken); };

        var run = _boundToken(opts.getToken).then(function (token) {   // Q-01 F1: receipt bound to this account first
            if (!token) {
                return syResult({ reason: 'Sign in to restore your data.' });
            }
            progress('Looking for a backup…');
            return Promise.all([_list(token, refresh), getState()]).then(function (got) {
                var index = syListingIndex(got[0]);
                var pendingDeletes = got[1].deletes || {};

                var wanted = [];
                var photoListing = [];
                var tombstones = { scans: [], properties: [], assessments: [] };
                var errors = [];
                var health = syHealthTally();
                Object.keys(index).forEach(function (key) {
                    var entry = index[key];
                    // Component photos are phase two (_restorePhotos): they
                    // depend on which assessments name them.
                    if (entry.namespace === SY_PHOTO_NS) {
                        photoListing.push({ id: entry.id, names: entry.names });
                        return;
                    }
                    // R5: a cloud tombstone beats every copy; nothing to fetch.
                    if (entry.names[SY_TOMBSTONE_FILE] && SY_TOMBSTONE_READERS.indexOf(entry.namespace) !== -1) {
                        tombstones[entry.namespace].push(entry.id);
                        return;
                    }
                    // R5: so does a delete made here whose tombstone has not landed yet.
                    if (pendingDeletes[key]) return;
                    wanted = wanted.concat(_wantedFor(entry, true));
                });

                var tombstoneCount = tombstones.scans.length + tombstones.properties.length + tombstones.assessments.length;
                if (wanted.length === 0 && photoListing.length === 0 && tombstoneCount === 0) {
                    _reportHealth([{ name: 'sync_restore_ok', props: { files: 0 } }], opts.getToken);
                    return syResult({ ok: true, reason: 'No backup found for this account yet.' });
                }

                return _download(wanted, opts.getToken, refresh, errors, health, progress, 'Downloading').then(function (fetched) {
                    var assembled = syAssemble(wanted, fetched);
                    return _restorePhotos(photoListing, assembled.records.assessments, errors, health, opts.getToken, refresh, progress)
                        .then(function (photoOutcome) {
                            progress('Merging…');
                            return _merge(assembled, tombstones, wanted.length, errors, health, opts.getToken, photoOutcome);
                        });
                });
            });
        })['catch'](function (err) {
            console.error('[SyncService] restore failed:', err);
            _reportHealth([{ name: 'sync_restore_failed', props: { errorClass: syErrorClass(err, 'list'), namespace: 'none', files: 0 } }], opts.getToken);
            return syResult({ reason: 'Restore did not finish. Check your connection and try again.', errors: [(err && err.message) || 'unknown error'] });
        }).then(function (result) {
            _pass = null;
            progress('');
            return result;
        });

        _pass = { kind: 'restore', promise: run };
        return run;
    }

    /**
     * _merge — offer the assembled records (and tombstones) to the importers,
     * record the outcome, and answer in the user's terms (R10).
     */
    function _merge(assembled, tombstones, fileCount, errors, health, tokenOverride, photoOutcome) {
        var photos = photoOutcome || { restored: 0, files: 0 };
        var counts = _offer(assembled.records, tombstones, errors, health);
        var restored = photos.restored + counts.added + counts.replaced + counts.merged;
        var unreadable = assembled.unreadable;

        if (window.MetricsService && errors.length === 0) MetricsService.track('restore_completed'); // product metrics §2
        if (health && health.size() > 0) {
            _reportHealth(health.events('sync_restore_failed'), tokenOverride);
        } else if (errors.length === 0) {
            _reportHealth([{ name: 'sync_restore_ok', props: { files: fileCount + photos.files } }], tokenOverride);
        }
        var reason = null;
        if (errors.length > 0) {
            reason = 'Some records did not finish. Everything that arrived is saved — try again to fetch the rest.';
        } else if (restored === 0 && counts.removed === 0) {
            reason = 'Everything here is already up to date.';
        }
        if (unreadable > 0) {
            reason = (reason ? reason + ' ' : '') + unreadable + ' item' + (unreadable === 1 ? ' was' : 's were') +
                ' saved by a newer version of the app and ' + (unreadable === 1 ? 'was' : 'were') + ' left as they are — update the app to restore ' +
                (unreadable === 1 ? 'it' : 'them') + '.';
        }
        return _saveState({
            lastRestoreAt: new Date().toISOString(),
            lastResult: errors.length === 0 ? 'ok' : (restored > 0 ? 'partial' : 'failed'),
            errorCount: errors.length
        }).then(function () {
            return syResult({
                ok: errors.length === 0,
                restored: restored,
                skipped: counts.kept,
                merged: counts.merged,
                removed: counts.removed,
                unreadable: unreadable,
                errors: errors,
                reason: reason
            });
        });
    }

    return {
        VERSION: SY_STATE_VERSION,
        CONTRACT_VERSION: SY_CONTRACT_VERSION,
        MAX_FILES: SY_MAX_FILES,
        init: init,
        isConfigured: isConfigured,
        endpoint: endpoint,
        getState: getState,
        backup: backup,
        restore: restore,
        deleteComponentPhoto: deleteComponentPhoto,
        deleteComponentPhotos: deleteComponentPhotos,
        deleteRecord: deleteRecord,
        deleteBrandingLogo: deleteBrandingLogo,
        pendingFileDeletes: pendingFileDeletes,
        mirror: mirror,
        pull: pull,
        bindAccount: bindAccount,
        pendingDeletes: pendingDeletes,
        pendingRecordDeletes: pendingRecordDeletes,
        registerImporter: registerImporter,
        importers: importers,
        registerScanLinker: registerScanLinker,
        scanOwnership: scanOwnership,
        linkScans: linkScans
    };
})();

/* ------------------------------------------------------------------ *
 * The display gate (React)
 * ------------------------------------------------------------------ */

/**
 * useCloudSync — the ONE place the Cloud backup surface's visibility and
 * enabled state are decided, the same idiom as `useReportBrandingEnabled`.
 *
 *   visible  — the `CLOUD_SYNC` flag is on AND an endpoint is configured.
 *              Either one missing hides the row entirely: there is nothing
 *              honest to show for a backend that does not exist.
 *   entitled — Professional+ (brief §4/§6 slice 6). `team` is Professional's
 *              marker in the Tiers matrix; the rank check is the belt to its
 *              braces, so a future tier that gains sync without `team` works.
 *   locked   — visible but not entitled: the row shows, disabled, with
 *              `SY_LOCKED_COPY`. `ENTITLEMENTS_PREVIEW_TIER` makes that
 *              testable before payments exist.
 */
var SY_LOCKED_COPY = 'Cloud backup is on the Professional plan';

var useCloudSync = function () {
    var flagOn = useFeatureFlag('CLOUD_SYNC');
    var ent = useEntitlements();
    var configured = SyncService.isConfigured();
    var entitled = !!ent.team || Tiers.isAtLeast(ent.tier, 'professional');
    return {
        visible: !!flagOn && configured,
        configured: configured,
        entitled: entitled,
        isPreview: !!ent.isPreview,
        lockedCopy: SY_LOCKED_COPY
    };
};

window.SyncService     = SyncService;
window.useCloudSync    = useCloudSync;
window.SY_LOCKED_COPY  = SY_LOCKED_COPY;
window.SY_MAX_FILES    = SY_MAX_FILES;
window.syValidPath     = syValidPath;
window.syChunk         = syChunk;
window.syDataUrlToBlob = syDataUrlToBlob;
window.syBlobToDataUrl = syBlobToDataUrl;
window.syStamp         = syStamp;
window.syRemoteWins    = syRemoteWins;
window.syScanMetadata  = syScanMetadata;
window.syScanOwnerClass = syScanOwnerClass;
window.syPartitionScans = syPartitionScans;
window.SY_SCAN_OMIT    = SY_SCAN_OMIT;
window.syBackupFiles   = syBackupFiles;
window.syBackupItems   = syBackupItems;
window.syItemFiles     = syItemFiles;
window.syEnvelope      = syEnvelope;
window.syUnwrap        = syUnwrap;
window.sySha256        = sySha256;
window.syBackoffMs     = syBackoffMs;
window.syIsBackingOff  = syIsBackingOff;
window.syMergeById     = syMergeById;
window.syNormaliseState = syNormaliseState;
window.syRecencyOrder  = syRecencyOrder;
window.syTokenSub      = syTokenSub;
window.syAccountPatch  = syAccountPatch;
window.SY_STATE_MAP_MAX = SY_STATE_MAP_MAX;
window.SY_BACKUP_SCHEMA = SY_BACKUP_SCHEMA;
window.SY_NAMESPACES   = SY_NAMESPACES;
window.syErrorClass    = syErrorClass;
window.syNamespaceOf   = syNamespaceOf;
window.syHealthTally   = syHealthTally;
window.syCleanHealthEvent = syCleanHealthEvent;
window.syPhotoIndex    = syPhotoIndex;
window.syPhotoRecord   = syPhotoRecord;
window.syPhotoPaths    = syPhotoPaths;
window.syPutProblem    = syPutProblem;
window.syByteLength    = syByteLength;
window.SY_PHOTO_NS     = SY_PHOTO_NS;
// Restore & conflict rules (contract 1.0) — pure, for the suites and the fixture.
window.syCanonical     = syCanonical;
window.syReadObject    = syReadObject;
window.syComparable    = syComparable;
window.syMergeRecord   = syMergeRecord;
window.syTombstoneBody = syTombstoneBody;
window.syListingIndex  = syListingIndex;
window.syAssemble      = syAssemble;
window.SY_CONTRACT_VERSION = SY_CONTRACT_VERSION;
window.SY_RECORD_VERSIONS  = SY_RECORD_VERSIONS;
window.SY_TOMBSTONE_FILE   = SY_TOMBSTONE_FILE;
window.SY_PHOTO_MAX_BYTES = SY_PHOTO_MAX_BYTES;
