Hub RFC 004 upgrade chains

RFC-004 — Upgrade Chains for Koder Hub

Field Value
RFC RFC-004
Title Upgrade Chains — Sequential Version Migration
Author Koder Engineering
Date 20260416
Status Draft
Supersedes

Table of Contents

  1. Abstract
  2. Motivation
  3. Background
  4. Goals and Non-Goals
  5. Design
  6. Worked Example
  7. Database Migration
  8. Security Considerations
  9. Failure Modes and Mitigations
  10. Compatibility
  11. Alternatives Considered
  12. Open Questions

1. Abstract

Some version upgrades involve breaking changes to on-disk data (config files, databases, caches, key formats) that make a direct jump from version A to version C unsafe unless the user first passes through an intermediate version B that performs the migration.

This RFC introduces *pgrade chains* a mechanism by which publishers declare a min_upgrade_from constraint on a version, and the Store returns a sequential upgrade path to clients rather than the absolute latest version. The client follows the path step by step, relaunching between each step, until it reaches the latest version.


2. Motivation

2.1 Current Behavior

UpdateCheckerService.checkSelfUpdate() (and the analogous logic for third-party apps) compares the running version against the latest published version and, if the latest is newer, installs it directly regardless of compatibility.

User at v1.3.0 → Store has v3.0.0 → Client installs v3.0.0 directly

2.2 The Problem

Consider an app that stores user preferences in a proprietary binary format until v2.0.0, then switches to JSON. v3.0.0 removes the legacy binary reader entirely. A direct upgrade from v1.3.0 → v3.0.0 would silently discard the user's config because the migration code (in v2.x) was never run.

Realworld analogs: PostgreSQL majorversion upgrades, encrypted storage key rotations, protocol version bumps in messaging apps, filesystem layout changes in desktop apps.

2.3 Why the Store Is the Right Layer

Requiring every publisher to embed versionrange guards inside the app itself is errorprone, hard to audit, and impossible to enforce retroactively. The Store already knows the full version history of every app — it is the natural place to encode and enforce upgrade constraints.


3. Background

The existing APP_VERSION model (see docs/technical/data-models.md) tracks:

APP_VERSION {
    id, app_id, version (semver), version_code (int),
    changelog, status, created_at
}

The update check (GET /api/v1/apps/:slug) returns the latest published version. The client compares and, if newer, downloads. No notion of "safe upgrade path" exists today.


4. Goals and Non-Goals

Goals

  • *1.*Let publishers declare that a version requires upgrading through a specific prior version.
  • *2.*The Store computes the correct upgrade path server-side; clients are not required to implement graph traversal.
  • *3.*Zero regression for apps with no upgrade chain constraints — behavior identical to today.
  • *4.*Works for both selfupdate (Koder Hub itself) and thirdparty apps.
  • *5.*The Flutter client handles multi-step upgrades transparently: each step feels like a normal update; the only visual difference is a step indicator when multiple steps are needed.
  • *6.*Publish tooling (publish.sh, CLI) surfaces the new field.

Non-Goals

  • *G1.*Downgrade paths — this RFC is upgrade-only.
  • *G2.*Per-platform version constraints — min_upgrade_from applies to all platforms of a given version.
  • *G3.*Branching paths (DAG) — the upgrade chain is always linear.
  • *G4.*Enforcing that the migration actually ran correctly (that is the app's responsibility).

5. Design

5.1 New Field: min_upgrade_from

Add an optional field to APP_VERSION:

APP_VERSION {
    ...existing fields...
    min_upgrade_from   TEXT NULL   -- nullable semver, e.g. "2.5.0"
}

*emantics:*"To install this version, the currently installed version must be ≥ min_upgrade_from."

  • NULL (default) → no constraint; any older version may upgrade directly.
  • "2.5.0" → this version requires the user to be on at least 2.5.0 before upgrading.

The field is set by the publisher at publish time and is immutable after the version reaches approved status.

5.2 Upgrade Path Algorithm

Given a client at version current, the server computes the *ext step*version:

function next_upgrade_step(current, versions):
    # versions = all published versions > current, sorted ascending by version_code
    for v in versions:
        if v.min_upgrade_from is null:
            return v                          # unconstrained — safe to install
        if semver_gte(current, v.min_upgrade_from):
            return v                          # constraint satisfied
        else:
            # v cannot be installed from current; must find a waypoint first.
            # Return the latest version that is:
            #   - published
            #   - >= current
            #   - < v.min_upgrade_from (i.e., the last safe version before the break)
            waypoint = latest_version_lt(v.min_upgrade_from)
            if waypoint is not null and semver_gt(waypoint, current):
                return waypoint
            # No waypoint found — skip this version (should not happen in a
            # well-published chain, but degrade gracefully by continuing).
    return null   # already at latest

The client calls this endpoint after each install. It never needs to know the full path upfront — repeated calls naturally walk the chain.

*ycle detection:*If min_upgrade_from ≥ the version itself (e.g., v2.0.0 requires ≥ v2.0.0), the server rejects the publish with 422 Unprocessable Entity.

5.3 Publish API Change

POST /api/v1/publish/:slug — new optional field:

min_upgrade_from   string (optional)   semver; e.g. "2.5.0"

Validation:

  • Must be valid semver when present.
  • Must be strictly less than version (prevents min_upgrade_from == version).
  • Must reference a version that exists and is published (warning, not hard error, to allow outoforder tooling).

The server stores the value in APP_VERSION.min_upgrade_from.

5.4 Update Check API Change

The existing endpoint GET /api/v1/apps/:slug returns the app object including version (the latest published version). Two additions:

*ption A (recommended): Single-field addition*

Add next_version to the response. When next_version == version (or absent), the client installs the latest directly. When they differ, next_version is a waypoint.

{
  "slug": "my-app",
  "version": "3.0.0",
  "next_version": "2.5.0",
  "next_version_step": 1,
  "total_upgrade_steps": 2,
  ...
}
  • next_version: the version the client should install next (may equal version if no chain constraint applies).
  • next_version_step / total_upgrade_steps: optional integers for the "Step N of M" UI. The server computes total steps by walking the full path once.

*ackward compatibility:*Older clients ignore next_version and install version directly. This is acceptable during the transition — once the client is updated, it will follow the path correctly.

*ption B: Dedicated endpoint*

GET /api/v1/apps/:slug/upgrade-path?from=:version

Returns the full ordered path. More explicit but requires a new endpoint.

This RFC recommends Option A for minimal surface change. Option B can be added later if richer tooling is needed.

5.5 Client Behavior

The Flutter UpdateCheckerService.checkSelfUpdate() (and the analogous update logic for third-party apps) is modified as follows:

*efore:*

final remote = StoreApp.fromJson(data);
if (_isNewerVersion(remote.version, appVersion)) {
  return remote;   // returns latest
}

*fter:*

final remote = StoreApp.fromJson(data);
// Use next_version if present (upgrade chain); fall back to version for
// backward compatibility with servers that haven't implemented RFC-004.
final target = remote.nextVersion ?? remote.version;
if (_isNewerVersion(target, appVersion)) {
  return remote.copyWith(
    effectiveVersion: target,           // what to install
    upgradeStep: remote.nextVersionStep,
    totalUpgradeSteps: remote.totalUpgradeSteps,
  );
}

Because the client relaunches after each install, and checkSelfUpdate is called on every launch, the chain is walked automatically:

Launch v1.0 → check → install v2.5 (waypoint) → relaunch
Launch v2.5 → check → install v3.0 (final) → relaunch
Launch v3.0 → check → already latest → no dialog

5.6 Self-Update Integration

_installSelfUpdate in main.dart already calls relaunchSelf() after a successful dpkg install on Linux. No changes needed — the relaunch triggers checkSelfUpdate on the newly launched binary, which will detect the next step if any.

For thirdparty apps (home screen update section), the same principle applies: after a successful install, the app rechecks on next launch.

5.7 Modal UX

When total_upgrade_steps > 1, the update modal title column shows a step indicator:

Updating Koder Hub          ← title
2.29.47 → 2.29.50             ← version subtitle
Step 1 of 3                   ← shown only when total_steps > 1

The step indicator is a small Text widget in the same Column as the version subtitle, using labelSmall style and primary color to make it visually distinct without being alarming.


6. Worked Example

Setup

Publisher releases versions with the following constraints:

Version min_upgrade_from Notes
1.0.0 null Initial release
1.5.0 null Backward-compatible improvements
2.0.0 null Adds JSON config migration code
2.5.0 null Last version with binary config reader
3.0.0 "2.0.0" Removes binary reader; needs JSON migration
3.1.0 null Backward-compatible with 3.0.0

Upgrade scenarios

*ser at v1.0.0, latest is v3.1.0:*

Step 1: server returns next_version="2.5.0" (latest before 3.0.0's constraint)
        client installs v2.5.0 → relaunches
Step 2: server returns next_version="3.1.0" (v3.0.0's constraint: >= 2.0.0 ✓ since user is now at 2.5.0)
        client installs v3.1.0 → relaunches
Step 3: server returns null (already latest)
        no dialog

Modal shows "Step 1 of 2" then "Step 2 of 2".

*ser at v2.5.0, latest is v3.1.0:*

Step 1: server returns next_version="3.1.0" (constraint ≥ 2.0.0 satisfied)
        client installs v3.1.0 → no further steps

One-step upgrade. No step indicator shown (total_steps =1).

*ser at v3.0.0, latest is v3.1.0:*

Standard direct upgrade — identical to current behavior.

7. Database Migration

ALTER TABLE app_versions
  ADD COLUMN min_upgrade_from TEXT DEFAULT NULL;

-- Index for efficient waypoint lookup
CREATE INDEX idx_app_versions_upgrade
  ON app_versions (app_id, version_code)
  WHERE status = 'published';

No data backfill needed — NULL means "no constraint" which is the correct default for all existing versions.


8. Security Considerations

8.1 Publisher Abuse

A malicious publisher could declare min_upgrade_from on a version to force users to remain on a known-vulnerable intermediate version. Mitigations:

  • The Store admin panel surfaces min_upgrade_from prominently in the review queue.
  • Reviewers must explicitly approve versions with non-null min_upgrade_from.
  • If a waypoint version is deprecated/yanked, the server skips it and uses the next available waypoint (or falls back to direct upgrade).

8.2 Infinite Loop

If a chain is misconfigured (e.g., v3.0.0 requires v2.5.0, but v2.5.0 requires v3.0.0), the algorithm would loop. Prevention:

  • Publish-time validation: min_upgrade_from must be strictly less than version.
  • Server-side loop detection: the path algorithm has a maximum depth of 20 steps; if exceeded, it falls back to direct upgrade and logs an alert.

8.3 Downgrade Attacks

The constraint is checked server-side. A client cannot bypass it by passing a fake current_version to gain access to a package it should not install — the worst outcome is getting a later waypoint than needed (harmless).


9. Failure Modes and Mitigations

Failure Mitigation
Waypoint version yanked/deprecated Skip it; walk to next safe version. Log warning.
min_upgrade_from references a non-existent version Treat as null (no constraint) with admin alert.
Server returns next_version the client is already at Client detects cycle (next =current), logs error, skips update.
Chain longer than 20 steps Server caps at 20 and returns directtolatest as fallback.
Old client (preRFC004) ignores next_version Installs latest directly; may break. Acceptable for apps that haven't declared a chain. For critical chains, publisher should set a minimum client version requirement.

10. Compatibility

Client Versions

  • *reRFC004 clients:*ignore next_version; install latest directly. Safe for apps without chain constraints. For apps with chains, this is the same risk as today — the publisher chose to publish a constrained version while old clients exist.
  • *ostRFC004 clients:*respect next_version; walk chain correctly.

Publishers who need strict enforcement should use RFC-003 attestation to signal a minimum client version requirement (future work).

Server Versions

  • *reRFC004 server:*does not return next_version. PostRFC004 clients fall back gracefully (treat as next_version == version).
  • *ostRFC004 server:*returns next_version. PreRFC004 clients ignore it.

Both transitions are safe.


11. Alternatives Considered

A. Full path returned upfront

GET /api/v1/apps/:slug/upgrade-path?from=X returns the entire path array.

*ejected:*More complex to implement and consume. The stepbystep approach (client calls update check after each relaunch) achieves the same result with no extra state management on the client, and naturally handles the case where a new version is published between steps.

B. Client-side path computation

Client downloads the full version list and computes the path locally.

*ejected:*Requires downloading potentially large version lists; duplicates business logic in every client platform; harder to fix bugs (requires client update to fix path algorithm).

C. Forceupgrade with inapp migration

App embeds migration code and handles upgrades across multiple versions internally, without Store involvement.

*ejected:*Places the entire burden on app developers; no enforcement; Store cannot help users who skip updates for months; does not generalize across apps.


12. Open Questions

  1. *hould min_upgrade_from be per-platform?*e.g., only Linux requires the waypoint because the config format changed only on Linux. Current design applies it globally to keep the model simple. Low priority for v1.
  2. *tep count accuracy:*total_upgrade_steps requires the server to walk the full path on every update check. For short chains (≤ 5 steps) this is negligible. For longer chains, should it be cached per (slug, from_version) pair?
  3. *anking a waypoint version:*If a publisher yanks the only waypoint between A and C, users on A cannot safely upgrade to C. Should the Store block the yank in this case? Or just warn?

Source: ../home/koder/dev/koder/meta/docs/stack/rfcs/hub-RFC-004-upgrade-chains.md