◉ANTIGRAVITY LABJP
Articles/Agents & Manager
◈ Agents & Manager/2026-10-14Advanced

When the App Reads an API Response by Position, I Put a Gate in Front of the Upload So Shipped Versions Don't Crash at Launch

A client that reads a server list by index, as a wallpaper app does, leaves old shipped versions in the wild for a long time. Deleting or reordering a field can crash them at launch. This note shows an append-only check and a hash ledger that freezes shipped version directories, with the output from actually running them.

API designbackward compatibilityindie developer14mobile appsCI10Antigravity380

✦ Premium Article

An app that has reached the store cannot be pulled back to suit my schedule. On the day a new version ships, an old one is still running on someone's phone, and it remembers the shape of the server response exactly as it was when it was released.

The wallpaper apps I run as an indie developer read the server's list by position. Item 1 is the ID, item 2 is the title, item 3 is the category. The apps never look at field names. That keeps things light and fast, but the moment the server drops one item or reorders two, a shipped version starts reading a different item than it expects. In my setup, that ends with the old version crashing at launch.

So I narrowed the rule to one sentence: a response that is read by position only ever gets items appended at the tail. No deletions, no reordering. A written rule does not hold by itself, though. It gets broken on days when I let an agent tidy the delivery tree, or late at night by hand. I decided to make a machine hold it.

This note walks through two gates, with the output from running them: a check that compares the shape of a response, and a ledger that freezes shipped version directories.

First, a one-page table of what is being protected

Before any code, I sorted changes into what may change and what may not. "Read by position" breaks in different ways depending on the kind of change.

Server-side changeEffect on shipped versionsPolicy
Append rows (new wallpapers) at the endOld versions just read the new rowsFree to do
Append an item to the end of each rowOld versions never read the unknown positionAllowed (tail only)
Delete an item in the middleEverything after it shifts one positionForbidden
Reorder itemsThe wrong item gets read; easy to miss when types matchForbidden
Delete or reorder shipped rowsReferences from favorites and notifications disappearForbidden
Update an item whose value naturally moves, such as a view countThe position does not changeAllowed (name the positions explicitly)

Rows three and four are where the accidents live. Adding rows is safe, and adding an item at the tail is safe. The only dangerous thing is moving the meaning of a position that has already shipped. That is the one thing the check has to watch.

Gate one: compare the shipped response with the one you are about to upload

The idea is simple. Keep the response as it was when you shipped, and compare the upload candidate against it. The check looks at four things.

  1. The shortest row has not gotten shorter.
  2. The type at each existing position has not changed.
  3. Treating the first column as the primary key, no shipped row has vanished or moved.
  4. Values on shipped rows are identical, except at positions explicitly marked as allowed to change.
#!/usr/bin/env python3
"""出荷済み版の応答(baseline)と、これから上げる応答(candidate)を比べ、
「位置で読む」クライアントを壊す変更だけを落とす。
使い方: append_only_check.py BASELINE.json CANDIDATE.json [--mutable 3,5]
--mutable: 出荷済みの行でも値が変わってよい列の位置(例: 閲覧数)。それ以外は値まで一致を求める
終了コード: 0=安全 / 1=壊す変更あり / 2=入力不正
"""
import json, sys
from collections import Counter
 
def kind(v):
    # 値でなく「型」を見る。壁紙が増えて値が変わるのは正常だから
    if isinstance(v, bool): return "bool"
    if isinstance(v, int): return "int"
    if isinstance(v, float): return "float"
    if isinstance(v, str): return "str"
    if v is None: return "null"
    return type(v).__name__
 
def column_kinds(rows):
    """列ごとの型の集合(null は「未設定」なので型の判定から外す)"""
    cols = {}
    for r in rows:
        for i, v in enumerate(r):
            if v is not None:
                cols.setdefault(i, set()).add(kind(v))
    return cols
 
def main(base_path, cand_path, mutable=frozenset()):
    try:
        base = json.load(open(base_path, encoding="utf-8"))
        cand = json.load(open(cand_path, encoding="utf-8"))
        assert all(isinstance(r, list) for r in base + cand)
    except Exception as e:
        print(f"入力不正: {e}"); return 2
 
    problems = []
    # 1) 行ごとの列数: 出荷済みの行より短くしない
    base_w = Counter(len(r) for r in base)
    cand_w = Counter(len(r) for r in cand)
    min_base, min_cand = min(base_w), min(cand_w)
    if min_cand < min_base:
        problems.append(f"最短の行が {min_base} 列 → {min_cand} 列に短くなっています")
 
    # 2) 既存の列の型が変わっていないか(位置 i の意味が入れ替わっていないか)
    bk, ck = column_kinds(base), column_kinds(cand)
    for i in sorted(bk):
        if i not in ck:
            problems.append(f"列 {i} が候補から消えています(型 {sorted(bk[i])})")
        elif not ck[i] <= bk[i]:
            problems.append(f"列 {i} の型が {sorted(bk[i])} → {sorted(ck[i])} に変わっています")
 
    # 3) 先頭列を主キーとみなし、出荷済みの行が消えていないか・並びが入れ替わっていないか
    base_ids = [r[0] for r in base]
    cand_ids = [r[0] for r in cand]
    if cand_ids[:len(base_ids)] != base_ids:
        gone = [x for x in base_ids if x not in set(cand_ids)]
        problems.append(f"出荷済みの行の先頭一致が崩れています(消えた行 {len(gone)} 件)"
                        if gone else "出荷済みの行の並びが入れ替わっています")
 
    # 4) 同じ型の列どうしの入れ替えは型だけでは見えない。出荷済みの行は値まで一致を見る
    by_id = {r[0]: r for r in cand}
    diffs = Counter()
    for r in base:
        c = by_id.get(r[0])
        if c is None: continue
        for i, v in enumerate(r):
            if i in mutable: continue
            if i >= len(c) or c[i] != v: diffs[i] += 1
    for i, n in sorted(diffs.items()):
        problems.append(f"列 {i}: 出荷済みの行 {n} 件で値が変わっています(入れ替えの疑い)")
 
    print(f"baseline: {len(base)} 行 / 列数 {dict(sorted(base_w.items()))}")
    print(f"candidate: {len(cand)} 行 / 列数 {dict(sorted(cand_w.items()))}")
    if problems:
        for p in problems: print("NG  " + p)
        return 1
    added_cols = max(cand_w) - max(base_w)
    print(f"OK  末尾追加のみ(追加行 {len(cand)-len(base)} 件・追加列 {max(added_cols,0)} 本)")
    return 0
 
if __name__ == "__main__":
    args = [a for a in sys.argv[1:]]
    mutable = frozenset()
    if "--mutable" in args:
        k = args.index("--mutable")
        mutable = frozenset(int(x) for x in args[k + 1].split(",") if x)
        del args[k:k + 2]
    if len(args) != 2:
        print(__doc__); sys.exit(2)
    sys.exit(main(args[0], args[1], mutable))

Here is what I got when I ran it. The baseline is a small 3-row, 4-column response. It is a stand-in to exercise the check, not the real response format.

--- ok (one row added, one column appended to every row)
baseline: 3 行 / 列数 {4: 3}
candidate: 4 行 / 列数 {5: 4}
OK  末尾追加のみ(追加行 1 件・追加列 1 本)
exit=0
 
--- drop (the third column removed entirely)
baseline: 3 行 / 列数 {4: 3}
candidate: 3 行 / 列数 {3: 3}
NG  最短の行が 4 列 → 3 列に短くなっています
NG  列 2 の型が ['str'] → ['int'] に変わっています
NG  列 3 が候補から消えています(型 ['int'])
NG  列 2: 出荷済みの行 3 件で値が変わっています(入れ替えの疑い)
exit=1
 
--- rowgone (the shipped second row removed)
NG  出荷済みの行の先頭一致が崩れています(消えた行 1 件)
exit=1

(The script's messages are in Japanese, as in my own tooling. They say, in order: the shortest row got shorter; a column's type changed; a column disappeared; values changed on shipped rows; the shipped rows no longer match at the head.)

✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Before you change a server response, you'll be able to inventory which positions shipped versions read and draw the line between what you may change and what can only be appended at the tail
✦You'll see why comparing types alone misses a swap of same-typed columns, and move a check that compares values on shipped rows into your own pre-upload script
✦By freezing shipped version directories with hashes, you can stop a change that would break old versions before upload, even on a day you let an agent tidy the delivery tree, and skip the time spent chasing crash reports from old versions
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

◈ Agents & Manager2026-10-14
I Expected Agent PRs to Wait Longer Than Human PRs, but 246 Public Pull Requests Showed the Opposite
A count of 246 July-August 2026 PRs in dotnet/runtime, comparing time to the first human reaction on agent-opened PRs and human-opened PRs. Includes the measurement script and a stale-PR check for solo projects.
◈ Agents & Manager2026-07-29
A Typo in agent.md Quietly Widened My Permissions — Writing a Strict Frontmatter Lint
Misspelled keys in agent.md frontmatter do not raise errors. They fall back to defaults, and for permission fields that fallback points the wrong way. Here is the failure I hit, the lint I wrote to catch it, and what the measurements showed.
◈ Agents & Manager2026-09-09
When an Antigravity Scheduled Task Never Runs: Check sidecar.json, Then enabled, Then the Logs
A checking order for Antigravity 2.0 Scheduled Tasks that quietly do nothing: where sidecar.json lives, how the directory name becomes the ID, the enabled flag in config.json, and the logs under sidecar_data.