Writing Indie SaaS ops

The code you typed is not the sync key

A short shop code looks like the identity. Sync may still be aiming at yesterday’s UUID.

You type a short shop code into settings. The UI shows SHOP-BBBB. Sync still talks to shop A.

That is not a network glitch. Two values lived in one mental slot: the human code and the sync UUID. The screen updated. The aim did not.

This note is tenant aim — which shop the device thinks it belongs to — not minting row ids for offline sales. Row UUIDs answer “who owns this event.” This one answers “which tenant does every push target.” Wrong aim is quieter and worse: every payload lands in the wrong shop.

Two fields, one lie

Settings need two fields whether the UI draws one box or two:

  • display code — what a person types and reads (SHOP-AAAA, SHOP-BBBB).
  • shop id — the UUID sync uses. Never the typed string.

A directory (cloud or cached) resolves code → id. Until resolve succeeds, the device is unlinked: it may show a code, but it must not sync.

The wrong model is: the short code is the shop. Then a code change looks like a rename of the same tenant. Sync keeps the old UUID. The UI and the wire diverge.

UI shows B; sync aims A

I ran node lab/human-code-vs-sync-id.mjs in this repo on 9 August 2026. Two shops. Device resolves A, then the operator edits the code to B without clearing the cached id.

  • UI: SHOP-BBBB
  • Cached shopId: still A’s UUID
  • Sync aim: still A

uiShowsBButSyncAimsA: true. The failure is not “B failed to resolve.” It is that sync never asked. It used yesterday’s aim while the label said otherwise.

Invariant: code change invalidates aim

Invariant: if displayCode changes, shopId is unknown until resolve says otherwise. Clear it on change. Refuse sync while unlinked. Resolve, then aim.

function setCode(next) {
  if (next !== displayCode) shopId = null;
  displayCode = next;
}

function sync() {
  if (!shopId) return { ok: false, reason: "unlinked" };
  // push aimed at shopId only
}

Clear-on-change does not resolve. It only stops lying. You still need a successful resolve before the next push. That gap is intentional: fail closed beats wrong tenant.

Dual-field design, on purpose

Keep both fields in state even if the form looks like one input. Sync paths read shopId only. Display and “which code did we last resolve” can show displayCode. Mixing them in one string is how the stale aim returns.

Decision boundary: reject “overwrite the UUID whenever the text field changes to a known code” without an explicit resolve step if your directory can lag or the typed string can be partial. Auto-resolve on every keystroke is a product choice; the invariant still holds — a pending or failed resolve must leave shopId null, not half-updated.

What this does not solve

Wrong code that still resolves (typo that hits another real shop) is a different bug: you aimed correctly at the wrong tenant. Clear-on-change does not catch that. Directory auth, confirm screens, and “are you sure this is your shop” are out of scope here. Row-level offline identity is a separate note; clearing tenant aim does not mint sale ids.

Lab: assertions, then evidence

Claims from that run (allPassed: true):

  1. Stale path: after code → B with old id kept, UI is B and sync aims A.
  2. Clear-on-change: after code → B, shopId is null; sync returns unlinked.
  3. After resolve to B, sync aims B’s UUID.
{
  "shops": {
    "A": { "id": "11111111-1111-4111-8111-111111111111", "code": "SHOP-AAAA" },
    "B": { "id": "22222222-2222-4222-8222-222222222222", "code": "SHOP-BBBB" }
  },
  "staleAim": {
    "displayCode": "SHOP-BBBB",
    "shopId": "11111111-1111-4111-8111-111111111111",
    "syncAimed": "11111111-1111-4111-8111-111111111111",
    "uiShowsBButSyncAimsA": true
  },
  "clearOnChange": {
    "afterCodeChange": { "displayCode": "SHOP-BBBB", "shopId": null },
    "syncWhileUnlinked": { "ok": false, "reason": "unlinked" },
    "afterResolve": {
      "displayCode": "SHOP-BBBB",
      "shopId": "22222222-2222-4222-8222-222222222222"
    },
    "syncAimed": "22222222-2222-4222-8222-222222222222"
  },
  "allPassed": true
}

Rerun: node lab/human-code-vs-sync-id.mjs. Ids and codes are fixed fixtures; the stale vs clear branches should stay identical.

Display code is for humans. Sync key is the UUID. When the typed code changes, the aim is void until resolve — or you will push into the shop the label no longer names.

A public notebook

Notes from real delivery: racey quotas, sync when a device is offline, messaging APIs, and the gap between a clean local demo and production.

Not a product catalog, a tutorial syllabus, or a course funnel. If a post names a tool, I used it. If it describes a failure, it happened.

About Contact