Skip to content

Release Management System — Low-Level Design (LLD)

Status: 🟢 active · Implements: HLD · Related: Process · Production state

How each component actually works: data model, state machine, portal modules, approval mechanics, dependency resolution, automation points, notifications, audit trail, and versioning.

This page establishes a convention

architecture/lld.md does not exist — CLAUDE.md lists HLD/LLD as mandatory portal deliverables and only the platform HLD was ever written. That gap is tracked separately; this is the first LLD in the portal and later ones should follow its shape.

Data model

The insight that drives the whole schema: a Release is not a Build, and a Release is not atomic. One release produces many builds (every store resubmission needs a new number) and lands on each surface independently.

Why SCOPE_ITEM references TRACKER_ROW rather than owning its own id: releases never mint ids. A second id space would fragment search and let a release describe work the tracker has never heard of. check:release verifies every scope id exists in tracker.md.

State machine

deployed is derived, never hand-set. check:release refuses it unless every entry in targets[] is terminal-success, and refuses partially_deployed once they all are. A store rejection is therefore a fact the record can express, not an error state someone has to explain.

Portal modules

Which is SSOT for what — the boundary that stops these four pages duplicating each other:

FactSSOT
Does this defect exist, and what is its id?tracker.md
Is it in this release, and did it ship?releases/<version>/release.json
How do I promote a migration?PROMOTION_RUNBOOK.md
Why is the architecture this way?architecture/adr/**
How did delivery feel, and what should change?delivery-log.md
What is live right now?releases/production-state.md

Where the register's computed numbers and the delivery log's narrative disagree, the delivery log's own rule applies: the numbers win.

Approval mechanics

An approval stores commit_sha + manifest_sha256. The gate recomputes the hash on every run, over the scope-bearing fields only. If the stored hash no longer matches, the approval is reported VOID and the release cannot deploy. That is what makes "what exactly was approved?" answerable, and it is the difference between an audit trail and a date in a table.

The definition lives in exactly one place — tools/release/manifest-hash.js.check-release.js imports it and deploy-prod.yml executes it, so the local gate and CI cannot disagree. Do not restate the formula here; a copy in prose is a copy that drifts.

Non-binding (changes legitimately during the deploy, must not void): approvals (self-referential) · status (lifecycle) · targets[] (per-surface progress) · builds[] (store resubmissions). Everything else binds, including fields nobody has thought of yet — the exclusion is a denylist on purpose, so an unrecognised field fails loudly rather than escaping the approval silently.

QRS-299 — this hash covered targets[] until 2026-08-03, and it would have blocked 26.0.1

The process page mandates "update targets[] as each lands". With targets in the hash, the first surface going live voided G4 and the gate then refused every remaining surface — the framework blocked its own documented deploy sequence, and the release that discovered it was the first one to use it. Two further defects sat in the same three lines: the formula was duplicated in three places, and JSON.stringify preserves insertion order, so reformatting release.json would have voided a live approval. Serialization is now key-sorted and deterministic.

Dependency resolution

depends_on on Change Records, resolved by depth-first traversal with a visiting/done colouring; a back-edge to a visiting node is a cycle and the trail is printed. A dependency naming a CR that is not in the release is also an error — it usually means a change was moved to another release and the reference was left behind.

Ordering is a correctness constraint, not paperwork: expand-contract means add-nullable → backfill → switch → drop, so applying CR-03 before CR-02 breaks production.

Production deployment

The read-back is the most important step. MCP apply_migration recorded versions that did not match the repo filenames and it went unnoticed through four applies because nobody read the table back (QRS-267). The CLI does not have that bug — but "does not have that bug" is a belief until something checks.

Change tracking and traceability

Multi-surface divergence

The case that shaped targets[] and the build ledger: Play approves, Apple rejects, and the platforms desynchronise permanently despite identical code.

Both stores allow a new build under the same version name, so users still see 26.0.1 on every surface. Only the internal integer differs, and the ledger records why.

Notifications

The event set is defined so that adding a channel later is configuration rather than redesign.

EventChannel todayUpgrade path
Gate failed (check:release, drift, fn-config)GitHub Actions failure email
Daily env-drift found REVERSE/DIVERGENTenv-drift workflow red
Scope freeze reachednone — computed, shown in the registerscheduled workflow → issue
Store submission outcomemanual — recorded by the operatorstore API polling
Open divergence past age thresholdred row in the registerscheduled workflow
Approval voided by a manifest changegate failure on the next run

Honest position: a failing CI run plus the release register is the notification system today, and that is adequate for one maintainer. Two events have no channel at all; they are listed rather than hidden. Building a Slack or email integration for a one-person team would be ceremony.

Audit trail

Defined by the questions it must answer, which is the only test that matters:

QuestionAnswered by
What shipped in 26.0.1?release.json scope[] + changes[], immutable in git history
Who approved it, and of what exactly?approvals[].commit_sha + manifest_sha256
What is live on iOS right now, from which commit?production-state.mdbuilds[].commit_sha
Every production change in a period?changes[] across releases, filterable by class
Why did this build number skip?builds[].supersedes + submission outcomes
Which gates were waived, by whom, why?gates[].waived_by
Was a console-only change actually verified?the CR's evidence anchor — a probe, not a claim

Git history is the immutable ledger. Release folders are append-only by convention, history is never rewritten, and the 90-day workflow artifacts corroborate the committed record.

Versioning and build mapping

Year-based (QRS-289): major = release year, minor = feature release, patch = fix.

versionCode = YY*1_000_000 + minor*10_000 + patch*100 + build

The build slot is per platform, because a store rejection desynchronises them. check:version therefore validates each platform independently: each must be a valid derivation of the same version for some build slot, and each must be monotonic per platform — which is what the stores actually require.

ChangeVersionBuilds
No code change (store metadata fix)unchangednew build, affected platform only
Code changenew patch, all surfacesnew builds everywhere
Platform-scoped code change (e.g. an iOS permission string)unchangednew build on that platform + written justification that the others are unaffected

Automation points

HookRunsFails
.husky/pre-pushcheck:releaseclosed
ci.yml workspace jobcheck:release + node --test tools/release/validate.test.mjsclosed
deploy-prod preflightall gates + manifest status/hash + declared-vs-pendingclosed
env-drift.ymldaily Dev↔Prod comparisonREVERSE/DIVERGENT

Exit codes are three-way everywhere: 0 pass · 1 the gate ran and found a violation · 2 the gate could not run. Conflating 1 and 2 is how an infrastructure failure gets read as a clean pass.

ci.yml must not ignore this directory

ci.yml sets paths-ignore: ['documentation/**', '**/*.md']. documentation/portal/releases/** is explicitly un-ignored — without that the gate never runs on the files it validates and the whole system is a green no-op, exactly the QRS-013 pattern.