Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
3cc5af3
Merge pull request #194 from Coding-Moves/main
Muawiya-contact Sep 12, 2026
d04df36
docs: record app engineering handbook scope
Muawiya-contact Sep 13, 2026
15fd0a3
docs: explain app architecture and complete learning lifecycle
Muawiya-contact Sep 13, 2026
159b564
docs: compare issue 195 sustainable learning design and current behavior
Muawiya-contact Sep 13, 2026
eff0673
docs: add printable handbook renderer and vector flowcharts
Muawiya-contact Sep 13, 2026
e0eb494
docs: define sustainable learning architecture and scope
Muawiya-contact Sep 13, 2026
6df5ffc
docs: record handbook validation and handoff
Muawiya-contact Sep 13, 2026
283475f
docs: record handbook PR handoff
Muawiya-contact Sep 13, 2026
88eb2de
Merge pull request #196 from Coding-Moves/codex/app-engineering-handbook
Muawiya-contact Sep 13, 2026
11adafc
fix: replenish shared lessons beyond the initial topic inventory
Muawiya-contact Sep 13, 2026
1a106c0
feat: add portable subjects and validated curriculum imports
Muawiya-contact Sep 13, 2026
72e3a9b
feat: require editorial review before publishing generated lessons
Muawiya-contact Sep 13, 2026
c5d305e
feat: add stable daily reviews with activity-based learning streaks
Muawiya-contact Sep 13, 2026
ebf4c28
feat: persist review completion through offline replay and account ch…
Muawiya-contact Sep 13, 2026
9bc36a8
feat: expose protected curriculum maintenance and content health oper…
Muawiya-contact Sep 13, 2026
49ad80f
fix: bound concurrent generation and recover abandoned editorial claims
Muawiya-contact Sep 13, 2026
64285a4
fix: sequence curriculum lessons and start refill after target commit
Muawiya-contact Sep 13, 2026
bd166ed
fix: fence progress cache writes during account cleanup
Muawiya-contact Sep 13, 2026
5a569b0
feat: offer daily review practice and separate review statistics
Muawiya-contact Sep 13, 2026
465a47a
test: exercise a year of learning and restore content backups
Muawiya-contact Sep 13, 2026
23799f9
fix: retain legacy lesson snapshots before publishing corrections
Muawiya-contact Sep 13, 2026
b6b863f
docs: document content architecture operations and verified handoff
Muawiya-contact Sep 13, 2026
e5c680b
docs: record sustainable learning PR handoff
Muawiya-contact Sep 13, 2026
c72313f
Merge develop handbook updates and preserve both work logs
Muawiya-contact Sep 13, 2026
9e99f04
fix: roll back rejected offline reviews before removing queued intent
Muawiya-contact Sep 13, 2026
b3e08de
docs: record rejected review regression and validation
Muawiya-contact Sep 13, 2026
3937927
Merge pull request #197 from Coding-Moves/codex/195-sustainable-learning
Muawiya-contact Sep 13, 2026
e2eed9b
feat: browse and cache complete learning history in bounded pages
Muawiya-contact Sep 13, 2026
afa8e96
fix: improve offline contrast and large-text account layouts
Muawiya-contact Sep 13, 2026
920c8d6
feat: add deliberate refresh controls across learning screens
Muawiya-contact Sep 13, 2026
3e1ed7b
docs: record learning experience validation and release handoff
Muawiya-contact Sep 13, 2026
80e14e1
docs: link combined learning experience PR
Muawiya-contact Sep 13, 2026
b38fd15
fix: use the public history cursor parameter
Muawiya-contact Sep 13, 2026
cc9b624
docs: record history cursor regression verification
Muawiya-contact Sep 13, 2026
4c0a0cc
Merge pull request #198 from Coding-Moves/codex/learning-experience-p…
Muawiya-contact Sep 13, 2026
6166d80
release: prepare 1.9.0 with one-time learning highlights
Muawiya-contact Sep 13, 2026
ac4452d
docs: record 1.9.0 validation and rollout prerequisites
Muawiya-contact Sep 13, 2026
73043bd
Merge pull request #199 from Coding-Moves/codex/1-9-0-release-prepara…
Muawiya-contact Sep 13, 2026
297eb6c
release: record verified production migrations 0011 through 0015
Muawiya-contact Sep 13, 2026
87ee0fa
docs: record production verification and remaining rollout steps
Muawiya-contact Sep 13, 2026
24d4b62
Merge pull request #201 from Coding-Moves/codex/1-9-0-verified-migrat…
Muawiya-contact Sep 13, 2026
fe5315b
fix: publish production OTA only after deployed revision confirmation
Muawiya-contact Sep 13, 2026
fd74b15
docs: explain backend-first release and local backup status
Muawiya-contact Sep 13, 2026
2c566b1
Merge pull request #202 from Coding-Moves/codex/1-9-0-release-gate
Muawiya-contact Sep 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/scripts/check-release-revision.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
# This validates an operator's deployment attestation, not Railway health itself.
if [[ "${GITHUB_REF:-}" != refs/heads/main ]]; then
echo '::error::Publish releases from main only.'
exit 1
fi
if [[ ! "${VERIFIED_BACKEND_SHA:-}" =~ ^[0-9a-f]{40}$ ]] ||
[[ "$VERIFIED_BACKEND_SHA" != "${GITHUB_SHA:-}" ]] ||
[[ "$VERIFIED_BACKEND_SHA" != "${REMOTE_MAIN_SHA:-}" ]]; then
echo '::error::Verify the current main commit on the production API and workers, then provide its full SHA.'
exit 1
fi
20 changes: 20 additions & 0 deletions .github/scripts/check-release-revision.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const {test}=require('node:test');
const assert=require('node:assert/strict');
const {spawnSync}=require('node:child_process');
const path=require('node:path');
const sha='a'.repeat(40), other='b'.repeat(40);
const baseline={...process.env,GITHUB_REF:'refs/heads/main',GITHUB_SHA:sha,VERIFIED_BACKEND_SHA:sha,REMOTE_MAIN_SHA:sha};
for(const [name,override,success] of [
['matching main deployment',{},true],
['preview branch',{GITHUB_REF:'refs/heads/develop'},false],
['tag dispatch',{GITHUB_REF:'refs/tags/v1.9.0'},false],
['missing attestation',{VERIFIED_BACKEND_SHA:''},false],
['short SHA',{VERIFIED_BACKEND_SHA:'aaaaaaa'},false],
['older backend',{VERIFIED_BACKEND_SHA:other},false],
['main advanced after dispatch',{REMOTE_MAIN_SHA:other},false],
['remote lookup failed',{REMOTE_MAIN_SHA:''},false],
['shell characters',{VERIFIED_BACKEND_SHA:'$(exit 0)'},false],
]) test(name,()=>{
const result=spawnSync('bash',[path.join(__dirname,'check-release-revision.sh')],{env:{...baseline,...override},encoding:'utf8'});
assert.equal(result.status,success?0:1,result.stderr+result.stdout);
});
19 changes: 1 addition & 18 deletions .github/workflows/eas-update.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Preview-channel OTA for the develop branch (the developer's own devices),
# plus a manual dispatch that publishes the chosen channel. Production OTA
# plus a manual preview dispatch. Production OTA
# for a release lives in release.yml, so the release tag is only cut after
# that publish succeeds. Native changes need a new build — see
# mobile/DEPLOYMENT.md.
Expand All @@ -15,12 +15,6 @@ on:
- "mobile/**"
- "!mobile/**.md"
workflow_dispatch:
inputs:
channel:
description: "Update channel (manual runs only)"
type: choice
default: preview
options: [preview, production]

jobs:
update:
Expand All @@ -46,18 +40,7 @@ jobs:

# --environment pulls the EXPO_PUBLIC_* variables from EAS into the
# bundle; without it the update ships with empty config and crashes.
- name: Publish OTA update (production)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.channel == 'production'
env:
MSG: ${{ github.event.head_commit.message || 'manual dispatch' }}
run: eas update --channel production --environment production --message "$MSG" --non-interactive

# A push to develop refreshes preview; a manual run publishes only the
# chosen channel.
- name: Publish OTA update (preview)
if: >-
(github.event_name == 'push') ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.channel == 'preview')
env:
MSG: ${{ github.event.head_commit.message || 'manual dispatch' }}
run: eas update --channel preview --environment preview --message "$MSG" --non-interactive
17 changes: 17 additions & 0 deletions .github/workflows/release-validation.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Release guard validation
on:
pull_request:
paths:
- '.github/scripts/check-release-revision*'
- '.github/workflows/release*.yml'
- '.github/workflows/eas-update.yml'
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
- run: bash -n .github/scripts/check-release-revision.sh
- run: node --test --test-isolation=none .github/scripts/check-release-revision.test.cjs
23 changes: 16 additions & 7 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# Merging a release PR into main cuts a versioned release. The production OTA
# publishes FIRST; the tag and GitHub Release are created only if that publish
# succeeds, so a Release can never claim a version that never reached phones.
# (Railway deploys the backend independently on the same push — see
# mobile/DEPLOYMENT.md; it is not gated here.)
# Railway deploys main independently. Publish only after the operator verifies
# the same commit on the API and workers and checks production health.
name: Release

on:
push:
branches: [main]
workflow_dispatch:
inputs:
backend_revision:
description: 'Full main commit SHA verified on healthy production API and workers'
required: true
type: string

# One release at a time: two quick merges must not race the tag check.
concurrency:
Expand All @@ -27,6 +28,14 @@ jobs:
steps:
- uses: actions/checkout@v7

- name: Verify deployed revision before publishing
env:
VERIFIED_BACKEND_SHA: ${{ inputs.backend_revision }}
run: |
REMOTE_MAIN_SHA=$(git ls-remote origin refs/heads/main | cut -f1)
export REMOTE_MAIN_SHA
bash ../.github/scripts/check-release-revision.sh

- uses: actions/setup-node@v7
with:
node-version: 22
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ venv/

# Firebase service-account keys are real secrets — never commit them
*firebase-adminsdk*.json

# Generated handbook PDFs and render/QA intermediates
/output/pdf/
/tmp/pdfs/
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ requires the exact Expo SDK 57 documentation before writing mobile code.
Use a descriptive `codex/` branch for a new chunk unless the owner specifies
a branch. Check the available base revision; do not assume local refs are current.
- `main` is production. A production release uses a `develop` to `main` PR and
the release runbook. Merging there triggers deployment and release automation.
the release runbook. Merging deploys the backend; publish the mobile release
separately after verifying the deployed API and worker revision.
- Every release PR must include a one-time What's New card. During release
preparation, automatically add a nonempty entry in `mobile/src/data/whatsNew.ts`
matching `mobile/app.config.js`'s version; do not wait for the owner to remind you.
Expand Down
19 changes: 15 additions & 4 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ Production release flow. Keep it boring and repeatable.
(This is our internal flow — the project doesn't take outside PRs; see
[CONTRIBUTING.md](CONTRIBUTING.md).)
- `main` is production. A release is a single PR **develop → main** (no `release/*` branch).
- Merging to `main` triggers `.github/workflows/release.yml`.
- Merging to `main` triggers Railway backend deployment. Mobile publication is
a separate manual run of `.github/workflows/release.yml` after verification.

## Cutting a release
1. **Bump the version on `develop` first.** Edit `mobile/app.config.js` → `expo.version`
Expand All @@ -26,9 +27,19 @@ Production release flow. Keep it boring and repeatable.
release PR.**
4. Open the release PR **develop → main**. It must pass the required
**"Migrations applied check"** and get its approval, then merge.
5. On merge, `release.yml` publishes the production + preview OTA, cuts the `vX.Y.Z`
tag + GitHub Release, and dispatches the APK build (which **skips** unless
runtimeVersion changed). Railway auto-deploys the `api` service from `main`.
5. On merge, Railway auto-deploys the API from `main`. Keep generation paused
during backend/worker transitions. Verify the new main commit is deployed to
the API and workers, `/health` succeeds, and the release-specific smoke checks
pass. Do not resume old generators against the new editorial schema.
6. Open **GitHub Actions → Release → Run workflow**, select **main**, and enter
the full 40-character main commit SHA you verified on production API/workers
in `backend_revision`. This is an operator attestation; the workflow does not
inspect Railway deployments itself. Do not submit it until checks are complete.
7. The workflow rejects another branch, an older deployment or a main revision
that advanced before validation. It then publishes production + preview OTA,
cuts the version tag/GitHub Release, and dispatches the native-gated APK build.
Do not merge another release while publication is running. The standalone
EAS Update workflow publishes preview only; production uses this release path.

## Database migrations — MANUAL, every release
The deploy does **not** auto-migrate. Files in `backend/migrations/*.sql` must be run
Expand Down
8 changes: 8 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,11 @@ GENERATION_ON_DEMAND=true
# --- HTTP -----------------------------------------------------------------
# Comma-separated. Expo web dev server during development.
ALLOWED_ORIGINS=http://localhost:8081

# Sustainable shared content supply and operations (see docs/CONTENT_OPERATIONS.md)
CONTENT_RESERVE_PER_TOPIC=60
CONTENT_LOW_WATERMARK=5
CONTENT_ACTIVE_DAYS=90
CONTENT_PLANNED_RESERVE=90
CONTENT_GENERATION_BATCH=5
GENERATION_MAX_CONCURRENT=3
11 changes: 11 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@ Status: **Phase 7 — reminders.** Reads, writes, Gemini generation from a
curated backlog, and timezone-aware push reminders that stop once the day is
learned.

## Sustainable content lifecycle

See [the architecture](../docs/CONTENT_ARCHITECTURE.md) and
[the operator runbook](../docs/CONTENT_OPERATIONS.md). Content now uses durable
reader-aware supply targets, structured curriculum imports, explicit reviewed
publication, and separate daily reviews when new lessons are exhausted.
Run `python -m app.workers.content --help` for maintainer operations.
Migrations 0011–0015 must precede this backend; keep old generators disabled
during rollout. Daily review clients opt into `reviews=true` on `/v1/me/state`
and complete an identified activity through `/v1/reviews/{id}/complete`.

## Layout

```
Expand Down
1 change: 1 addition & 0 deletions backend/app/api/v1/daily.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ async def get_daily(
topic_slug=concept.topic_slug,
topic_name=concept.topic_name,
like_count=concept.like_count,
content_version=concept.content_version,
),
)

Expand Down
32 changes: 27 additions & 5 deletions backend/app/api/v1/me.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,16 @@

from app.db.session import get_db
from app.deps import CurrentUser, get_current_user
from app.schemas.daily import ConceptOut, DailyOut
from app.schemas.daily import ConceptOut, DailyOut, ReviewOut
from app.schemas.me import (
HistoryPageOut, LearnedOut, ProfileIn, SavedConceptOut, SavedPageOut, StateOut,
StreakOut, TopicsIn,
HistoryPageOut,
LearnedOut,
ProfileIn,
SavedConceptOut,
SavedPageOut,
StateOut,
StreakOut,
TopicsIn,
)
from app.schemas.notifications import NotificationPrefs, PushTokenIn
from app.services.collections import history_page, saved_page
Expand Down Expand Up @@ -40,6 +46,7 @@ def _daily_out_or_none(result: DailyResult) -> DailyOut | None:
concept=ConceptOut(
id=c.id, slug=c.slug, title=c.title, summary=c.summary, example=c.example,
topic_slug=c.topic_slug, topic_name=c.topic_name, like_count=c.like_count,
content_version=c.content_version,
),
)

Expand Down Expand Up @@ -73,14 +80,21 @@ def _to_state_out(state) -> StateOut:
@router.get("/state", response_model=StateOut)
async def get_state(
compact: bool = False,
reviews: bool = False,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> StateOut:
"""Everything the app needs to render, in one request.

Bootstrapping only runs when the state query finds no profile, so the
common path costs a single round trip.
profile lock keeps the state totals consistent with concurrent completions.
"""
# Keep completion totals and the folded activity from the same point in
# time when another device completes a review during startup.
await db.execute(
text("select id from public.profiles where id=:uid for update"),
{"uid": user.id},
)
state = await load_state(db, user.id, compact=compact)
if state is None:
await ensure_bootstrapped(db, user.id, user.email)
Expand All @@ -89,7 +103,15 @@ async def get_state(
out = _to_state_out(state)
# Fold today's concept in so the app needs one startup round trip (#102).
# Same create-on-first-call behaviour as GET /v1/daily.
out.daily = _daily_out_or_none(await get_or_create_daily(db, user.id))
result = await get_or_create_daily(db, user.id, allow_review=reviews)
out.daily = _daily_out_or_none(result)
if result.status == "review":
out.review = ReviewOut(
review_id=result.review_id,assigned_for=result.assigned_for,
assigned_at=result.assigned_at,completed_at=result.completed_at,
learned=result.completed_at is not None,
concept=ConceptOut(**vars(result.concept)),
)
return out


Expand Down
26 changes: 26 additions & 0 deletions backend/app/api/v1/reviews.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import uuid

from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession

from app.db.session import get_db
from app.deps import CurrentUser, get_current_user
from app.schemas.me import CompletedOut, StreakOut
from app.services.reviews import complete_review
from app.services.streaks import compute_streaks

router = APIRouter(prefix="/reviews", tags=["reviews"])


@router.post("/{review_id}/complete", response_model=CompletedOut)
async def complete(
review_id: uuid.UUID,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
day = await complete_review(db, user.id, review_id)
return CompletedOut(
completed=True,
assigned_for=day,
stats=StreakOut(**vars(await compute_streaks(db, user.id))),
)
4 changes: 3 additions & 1 deletion backend/app/api/v1/router.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from fastapi import APIRouter

from app.api.v1 import concepts, daily, me, topics
from app.api.v1 import concepts, daily, me, reviews, topics

api_router = APIRouter(prefix="/v1")
api_router.include_router(topics.router)
api_router.include_router(daily.router)
api_router.include_router(concepts.router)
api_router.include_router(me.router)

api_router.include_router(reviews.router)
8 changes: 7 additions & 1 deletion backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,14 @@ class Settings(BaseSettings):
gemini_api_key: str = ""
gemini_model: str = "gemini-3.1-flash-lite"
generation_enabled: bool = False
min_pool_per_topic: int = 25
min_pool_per_topic: int = Field(default=25, ge=0)
content_reserve_per_topic: int = Field(default=60, ge=1, le=365)
content_low_watermark: int = Field(default=5, ge=0, le=30)
content_active_days: int = Field(default=90, ge=1, le=365)
content_planned_reserve: int = Field(default=90, ge=1, le=1000)
content_generation_batch: int = Field(default=5, ge=1, le=25)
# Shared by all generation paths; zero prevents new reservations.
generation_max_concurrent: int = Field(default=3, ge=1, le=20)
generation_daily_call_cap: int = Field(default=200, ge=0)
# Seconds between worker calls; the free tier allows ~10 requests a minute.
generation_pace_seconds: float = 6.0
Expand Down
5 changes: 4 additions & 1 deletion backend/app/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
Time,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import ARRAY
from sqlalchemy.dialects.postgresql import ARRAY, JSONB
from sqlalchemy.dialects.postgresql import UUID as PgUUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column

Expand Down Expand Up @@ -63,6 +63,9 @@ class Concept(Base):
difficulty: Mapped[int | None] = mapped_column(SmallInteger)
status: Mapped[str] = mapped_column(Text, nullable=False, default="published")
source: Mapped[str] = mapped_column(Text, nullable=False, default="seed")
content_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
curriculum: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Provenance of a generated lesson (null for seeded rows).
model: Mapped[str | None] = mapped_column(Text)
prompt_version: Mapped[str | None] = mapped_column(Text)
Expand Down
5 changes: 5 additions & 0 deletions backend/app/schemas/daily.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class ConceptOut(BaseModel):
topic_name: str
# Likes from other users; the client adds the viewer's own like.
like_count: int = 0
content_version: int = 1


class DailyOut(BaseModel):
Expand All @@ -31,3 +32,7 @@ class DailyExhaustedOut(BaseModel):
assigned_for: date
reason: str = "catalog_exhausted"
detail: str = "You have already been assigned every available concept."


class ReviewOut(DailyOut):
review_id: uuid.UUID
Loading