Appearance
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:
| Fact | SSOT |
|---|---|
| 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.
| Event | Channel today | Upgrade path |
|---|---|---|
Gate failed (check:release, drift, fn-config) | GitHub Actions failure email | — |
| Daily env-drift found REVERSE/DIVERGENT | env-drift workflow red | — |
| Scope freeze reached | none — computed, shown in the register | scheduled workflow → issue |
| Store submission outcome | manual — recorded by the operator | store API polling |
| Open divergence past age threshold | red row in the register | scheduled workflow |
| Approval voided by a manifest change | gate 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:
| Question | Answered 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.md → builds[].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 + buildThe 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.
| Change | Version | Builds |
|---|---|---|
| No code change (store metadata fix) | unchanged | new build, affected platform only |
| Code change | new patch, all surfaces | new builds everywhere |
| Platform-scoped code change (e.g. an iOS permission string) | unchanged | new build on that platform + written justification that the others are unaffected |
Automation points
| Hook | Runs | Fails |
|---|---|---|
.husky/pre-push | check:release | closed |
ci.yml workspace job | check:release + node --test tools/release/validate.test.mjs | closed |
deploy-prod preflight | all gates + manifest status/hash + declared-vs-pending | closed |
env-drift.yml | daily Dev↔Prod comparison | REVERSE/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.