diff --git a/.github/workflows/cloudflare-backup.yml b/.github/workflows/cloudflare-backup.yml
index a258494..ff2698f 100644
--- a/.github/workflows/cloudflare-backup.yml
+++ b/.github/workflows/cloudflare-backup.yml
@@ -33,7 +33,7 @@ jobs:
- name: Validate backup configuration before installing dependencies
shell: bash
env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_D1_BACKUP_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/cloudflare/backup-preflight.ts --phase credentials
@@ -44,7 +44,7 @@ jobs:
- name: Verify read access to both remote D1 databases
shell: bash
env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
@@ -61,7 +61,7 @@ jobs:
- name: Export catalog and pipeline databases
shell: bash
env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
@@ -115,7 +115,7 @@ jobs:
- name: Upload daily and monthly copies
shell: bash
env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
run: |
set -euo pipefail
@@ -131,12 +131,45 @@ jobs:
day="$(date -u +%F)"
month="$(date -u +%Y-%m)"
- upload_object "studyinchina-releases/backups/daily/$day/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/sql" --content-encoding="gzip"
- upload_object "studyinchina-releases/backups/daily/$day/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/sql" --content-encoding="gzip"
- upload_object "studyinchina-releases/backups/daily/$day/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain"
- upload_object "studyinchina-releases/backups/monthly/$month/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/sql" --content-encoding="gzip"
- upload_object "studyinchina-releases/backups/monthly/$month/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/sql" --content-encoding="gzip"
- upload_object "studyinchina-releases/backups/monthly/$month/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain"
+ upload_object "studyinchina-backups/backups/daily/$day/raw-v1/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/gzip" --content-encoding="identity"
+ upload_object "studyinchina-backups/backups/daily/$day/raw-v1/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/gzip" --content-encoding="identity"
+ upload_object "studyinchina-backups/backups/daily/$day/raw-v1/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain"
+ upload_object "studyinchina-backups/backups/monthly/$month/raw-v1/catalog.sql.gz" "$RUNNER_TEMP/catalog.sql.gz" --content-type="application/gzip" --content-encoding="identity"
+ upload_object "studyinchina-backups/backups/monthly/$month/raw-v1/pipeline.sql.gz" "$RUNNER_TEMP/pipeline.sql.gz" --content-type="application/gzip" --content-encoding="identity"
+ upload_object "studyinchina-backups/backups/monthly/$month/raw-v1/sha256.txt" "$RUNNER_TEMP/backup-sha256.txt" --content-type="text/plain"
+
+ - name: Read back and cryptographically verify daily checkpoint
+ shell: bash
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_BACKUP_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ set -euo pipefail
+ day="$(date -u +%F)"
+ readback="$RUNNER_TEMP/backup-readback"
+ mkdir -p "$readback"
+ download_object() {
+ local object="$1"
+ local file="$2"
+ if ! npx wrangler r2 object get "$object" --file "$file" --remote; then
+ echo "::error title=R2 backup readback failed::Unable to read back ${object}. The checkpoint must not be counted toward RPO."
+ exit 1
+ fi
+ }
+ download_object "studyinchina-backups/backups/daily/$day/raw-v1/catalog.sql.gz" "$readback/catalog.sql.gz"
+ download_object "studyinchina-backups/backups/daily/$day/raw-v1/pipeline.sql.gz" "$readback/pipeline.sql.gz"
+ download_object "studyinchina-backups/backups/daily/$day/raw-v1/sha256.txt" "$readback/backup-sha256.txt"
+ node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/cloudflare/backup-preflight.ts \
+ --phase artifacts --directory "$readback" \
+ | tee "$RUNNER_TEMP/backup-verification.json"
+
+ - name: Upload machine-readable backup verification
+ uses: actions/upload-artifact@v6
+ with:
+ name: d1-backup-verification-${{ github.run_id }}
+ path: ${{ runner.temp }}/backup-verification.json
+ if-no-files-found: error
+ retention-days: 35
- name: Explain an incomplete backup
if: ${{ failure() }}
@@ -150,7 +183,7 @@ jobs:
echo '- Configuration failure: add or correct the named GitHub Actions repository secret, then rerun.'
echo '- D1 access failure: verify token scope, account, database names and Cloudflare availability.'
echo '- Export/artifact failure: inspect the first failing step; nothing is uploaded before checksum verification.'
- echo '- R2 upload failure: treat the checkpoint as incomplete even if some objects were written, then rerun the whole job.'
+ echo '- R2 upload/readback failure: treat the checkpoint as incomplete even if some objects were written, then rerun the whole job.'
echo
echo 'Runbook: `docs/backup-and-restore.md#failure-semantics-and-triage`.'
} >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/cloudflare-restore-drill.yml b/.github/workflows/cloudflare-restore-drill.yml
index 3f37abc..7c59e32 100644
--- a/.github/workflows/cloudflare-restore-drill.yml
+++ b/.github/workflows/cloudflare-restore-drill.yml
@@ -22,10 +22,13 @@ jobs:
name: Restore both D1 backups into local isolated databases
runs-on: ubuntu-latest
timeout-minutes: 60
+ environment: cloudflare-restore-drill
steps:
- name: Check out repository
uses: actions/checkout@v6
+ with:
+ persist-credentials: false
- name: Use Node.js 24
uses: actions/setup-node@v6
@@ -33,6 +36,14 @@ jobs:
node-version: 24
cache: npm
+ - name: Validate protected restore credentials before installing dependencies
+ shell: bash
+ env:
+ CLOUDFLARE_D1_RESTORE_TOKEN: ${{ secrets.CLOUDFLARE_D1_RESTORE_TOKEN }}
+ CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
+ run: |
+ node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/cloudflare/backup-preflight.ts --phase restore-credentials
+
- name: Install dependencies
run: npm ci
@@ -52,7 +63,7 @@ jobs:
- name: Download private monthly backup
shell: bash
env:
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_D1_RESTORE_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
BACKUP_MONTH: ${{ steps.backup.outputs.month }}
run: |
@@ -60,9 +71,9 @@ jobs:
test -n "$CLOUDFLARE_ACCOUNT_ID"
input="$RUNNER_TEMP/restore-input"
mkdir -p "$input"
- npx wrangler r2 object get "studyinchina-releases/backups/monthly/$BACKUP_MONTH/catalog.sql.gz" --file "$input/catalog.sql.gz" --remote
- npx wrangler r2 object get "studyinchina-releases/backups/monthly/$BACKUP_MONTH/pipeline.sql.gz" --file "$input/pipeline.sql.gz" --remote
- npx wrangler r2 object get "studyinchina-releases/backups/monthly/$BACKUP_MONTH/sha256.txt" --file "$input/backup-sha256.txt" --remote
+ npx wrangler r2 object get "studyinchina-backups/backups/monthly/$BACKUP_MONTH/raw-v1/catalog.sql.gz" --file "$input/catalog.sql.gz" --remote
+ npx wrangler r2 object get "studyinchina-backups/backups/monthly/$BACKUP_MONTH/raw-v1/pipeline.sql.gz" --file "$input/pipeline.sql.gz" --remote
+ npx wrangler r2 object get "studyinchina-backups/backups/monthly/$BACKUP_MONTH/raw-v1/sha256.txt" --file "$input/backup-sha256.txt" --remote
# This step intentionally receives no Cloudflare credentials. The script
# supports local isolated D1 only and cannot overwrite a remote database.
diff --git a/.github/workflows/vercel-production-alias.yml b/.github/workflows/vercel-production-alias.yml
index 91af004..2a2b86b 100644
--- a/.github/workflows/vercel-production-alias.yml
+++ b/.github/workflows/vercel-production-alias.yml
@@ -4,11 +4,12 @@ on:
deployment_status:
permissions:
+ actions: read
contents: read
concurrency:
group: vercel-production-alias
- cancel-in-progress: true
+ cancel-in-progress: false
jobs:
promote:
@@ -17,7 +18,7 @@ jobs:
github.event.deployment_status.state == 'success' &&
github.event.deployment.environment == 'Production'
runs-on: ubuntu-latest
- timeout-minutes: 10
+ timeout-minutes: 40
env:
DEPLOYMENT_SHA: ${{ github.event.deployment.sha }}
DEPLOYMENT_URL: ${{ github.event.deployment_status.environment_url }}
@@ -47,8 +48,70 @@ jobs:
echo 'matches=false' >> "$GITHUB_OUTPUT"
echo "::notice::Deployment ${DEPLOYMENT_SHA} is not current main ${main_sha}; the stable alias will not be changed."
- - name: Require stable-alias credential
+ - name: Wait for successful CI on the exact deployment SHA
if: steps.main.outputs.matches == 'true'
+ id: ci
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/actions/workflows/ci.yml/runs?branch=main&event=push&per_page=20"
+ for attempt in $(seq 1 60); do
+ response="$(curl --fail --silent --show-error \
+ --header 'Accept: application/vnd.github+json' \
+ --header "Authorization: Bearer ${GITHUB_TOKEN}" \
+ --header 'X-GitHub-Api-Version: 2022-11-28' \
+ "${api}")"
+ if jq -e --arg sha "${DEPLOYMENT_SHA}" \
+ '.workflow_runs | any(.head_sha == $sha and .status == "completed" and .conclusion == "success")' \
+ <<< "${response}" >/dev/null; then
+ echo 'passed=true' >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ if jq -e --arg sha "${DEPLOYMENT_SHA}" \
+ '.workflow_runs | any(.head_sha == $sha and .status == "completed" and (.conclusion | IN("failure", "cancelled", "timed_out", "action_required")))' \
+ <<< "${response}" >/dev/null; then
+ echo "::error title=Production promotion blocked::CI completed unsuccessfully for deployment SHA ${DEPLOYMENT_SHA}; the stable alias was not changed."
+ exit 1
+ fi
+ echo "Waiting for successful CI on ${DEPLOYMENT_SHA} (${attempt}/60)."
+ sleep 30
+ done
+ echo "::error title=Production promotion timed out::No successful main push CI run was observed for deployment SHA ${DEPLOYMENT_SHA}; the stable alias was not changed."
+ exit 1
+
+ - name: Reconfirm deployment SHA is still current main
+ if: >-
+ steps.main.outputs.matches == 'true' &&
+ steps.ci.outputs.passed == 'true'
+ id: current
+ shell: bash
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
+ run: |
+ set -euo pipefail
+ ref_api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/heads/main"
+ current_sha="$(curl --fail --silent --show-error \
+ --header 'Accept: application/vnd.github+json' \
+ --header "Authorization: Bearer ${GITHUB_TOKEN}" \
+ --header 'X-GitHub-Api-Version: 2022-11-28' \
+ "${ref_api}" | jq -r '.object.sha // empty')"
+ if ! [[ "${current_sha}" =~ ^[0-9a-f]{40}$ ]]; then
+ echo '::error title=Production promotion blocked::GitHub did not return a valid current main SHA; the stable alias was not changed.'
+ exit 1
+ fi
+ if [[ "${DEPLOYMENT_SHA}" == "${current_sha}" ]]; then
+ echo 'matches=true' >> "$GITHUB_OUTPUT"
+ exit 0
+ fi
+ echo 'matches=false' >> "$GITHUB_OUTPUT"
+ echo "::notice::Main advanced to ${current_sha} while CI was running; deployment ${DEPLOYMENT_SHA} will not receive the stable alias."
+
+ - name: Require stable-alias credential
+ if: >-
+ steps.current.outputs.matches == 'true' &&
+ steps.ci.outputs.passed == 'true'
id: credential
shell: bash
env:
@@ -71,7 +134,8 @@ jobs:
- name: Validate deployment URL
if: >-
- steps.main.outputs.matches == 'true' &&
+ steps.current.outputs.matches == 'true' &&
+ steps.ci.outputs.passed == 'true' &&
steps.credential.outputs.configured == 'true'
shell: bash
run: |
@@ -83,7 +147,8 @@ jobs:
- name: Verify immutable deployment release API
if: >-
- steps.main.outputs.matches == 'true' &&
+ steps.current.outputs.matches == 'true' &&
+ steps.ci.outputs.passed == 'true' &&
steps.credential.outputs.configured == 'true'
shell: bash
run: |
@@ -91,7 +156,9 @@ jobs:
release_api="${DEPLOYMENT_URL%/}/api/v1/releases/current"
for attempt in 1 2 3 4 5 6; do
if curl --fail --silent --show-error "${release_api}" \
- | jq -e '.data.id and .data.recordCounts.programs' >/dev/null; then
+ | jq -e --arg sha "${DEPLOYMENT_SHA}" \
+ '.data.deploymentSha == $sha and .data.id
+ and (.data.publicCounts.programs | type == "number" and . > 0)' >/dev/null; then
exit 0
fi
sleep 10
@@ -101,41 +168,121 @@ jobs:
- name: Use Node.js 24
if: >-
- steps.main.outputs.matches == 'true' &&
+ steps.current.outputs.matches == 'true' &&
+ steps.ci.outputs.passed == 'true' &&
steps.credential.outputs.configured == 'true'
uses: actions/setup-node@v6
with:
node-version: 24
- - name: Promote stable production alias
+ - name: Promote stable production alias transaction and verify release API
if: >-
- steps.main.outputs.matches == 'true' &&
+ steps.current.outputs.matches == 'true' &&
+ steps.ci.outputs.passed == 'true' &&
steps.credential.outputs.configured == 'true'
shell: bash
env:
+ GITHUB_TOKEN: ${{ github.token }}
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
run: |
set -euo pipefail
+
+ previous_target=''
+ mutation_attempted=false
+ transaction_committed=false
+
+ github_main_sha() {
+ curl --fail --silent --show-error \
+ --header 'Accept: application/vnd.github+json' \
+ --header "Authorization: Bearer ${GITHUB_TOKEN}" \
+ --header 'X-GitHub-Api-Version: 2022-11-28' \
+ "${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/git/ref/heads/main" \
+ | jq -r '.object.sha // empty'
+ }
+
+ current_stable_target() {
+ npx --yes vercel@58.0.0 alias list \
+ --format json \
+ --limit 100 \
+ --scope henry-yangs-projects-c9706eac \
+ --token "${VERCEL_TOKEN}" \
+ | jq -er '
+ [.aliases[] | select(.alias == "studyinchina.vercel.app")] as $matches
+ | if ($matches | length) == 1 then $matches[0].url
+ else error("stable alias must have exactly one current target")
+ end
+ '
+ }
+
+ rollback_on_failure() {
+ status=$?
+ trap - EXIT
+ if [[ "${status}" -eq 0 || "${mutation_attempted}" != 'true' || "${transaction_committed}" == 'true' ]]; then
+ exit "${status}"
+ fi
+
+ echo "::error title=Stable alias transaction failed::Rolling studyinchina.vercel.app back to ${previous_target}."
+ if ! npx --yes vercel@58.0.0 alias set \
+ "${previous_target}" \
+ studyinchina.vercel.app \
+ --scope henry-yangs-projects-c9706eac \
+ --token "${VERCEL_TOKEN}"; then
+ echo '::error title=Stable alias rollback failed::The previous target could not be restored; immediate operator action is required.'
+ exit 1
+ fi
+
+ for attempt in 1 2 3 4 5 6; do
+ restored_target="$(current_stable_target || true)"
+ if [[ "${restored_target}" == "${previous_target}" ]]; then
+ echo "::notice title=Stable alias rollback verified::studyinchina.vercel.app again targets ${previous_target}."
+ exit "${status}"
+ fi
+ sleep 10
+ done
+
+ echo "::error title=Stable alias rollback verification failed::Expected ${previous_target}, but the stable alias did not return to that immutable target. Immediate operator action is required."
+ exit 1
+ }
+
+ trap rollback_on_failure EXIT
+
+ previous_target="$(current_stable_target)"
+ if ! [[ "${previous_target}" =~ ^studyinchina-[a-z0-9-]+\.vercel\.app$ ]]; then
+ echo "Unexpected previous stable-alias target: ${previous_target}" >&2
+ exit 1
+ fi
+
+ final_main_sha="$(github_main_sha || true)"
+ if ! [[ "${final_main_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ "${final_main_sha}" != "${DEPLOYMENT_SHA}" ]]; then
+ echo "::error title=Production promotion blocked::Main is ${final_main_sha:-unavailable}, not deployment ${DEPLOYMENT_SHA}; the stable alias was not changed."
+ exit 1
+ fi
+
+ mutation_attempted=true
npx --yes vercel@58.0.0 alias set \
"${DEPLOYMENT_URL}" \
studyinchina.vercel.app \
--scope henry-yangs-projects-c9706eac \
--token "${VERCEL_TOKEN}"
- - name: Verify stable alias release API
- if: >-
- steps.main.outputs.matches == 'true' &&
- steps.credential.outputs.configured == 'true'
- shell: bash
- run: |
- set -euo pipefail
+ post_promotion_main_sha="$(github_main_sha || true)"
+ if ! [[ "${post_promotion_main_sha}" =~ ^[0-9a-f]{40}$ ]] || [[ "${post_promotion_main_sha}" != "${DEPLOYMENT_SHA}" ]]; then
+ echo "::error title=Production promotion raced with main::Main advanced to ${post_promotion_main_sha:-unavailable} while the alias was changing."
+ exit 1
+ fi
+
for attempt in 1 2 3 4 5 6; do
if curl --fail --silent --show-error \
https://studyinchina.vercel.app/api/v1/releases/current \
- | jq -e '.data.id and .data.recordCounts.programs' >/dev/null; then
+ | jq -e --arg sha "${DEPLOYMENT_SHA}" \
+ '.data.deploymentSha == $sha
+ and (.data.id | type == "string" and length > 0)
+ and (.data.publicCounts.programs | type == "number" and . > 0)' >/dev/null; then
+ transaction_committed=true
+ echo "::notice title=Stable alias promotion verified::studyinchina.vercel.app serves deployment ${DEPLOYMENT_SHA}."
exit 0
fi
sleep 10
done
- echo 'The stable production alias did not pass the release API smoke test.' >&2
+ echo 'The stable production alias did not pass the release API smoke test; the previous immutable target will be restored.' >&2
exit 1
diff --git a/.gitignore b/.gitignore
index 05cc49e..692fd37 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ artifacts/official-harvest/
.wrangler/
.vercel/
.wrangler-*/
+.codex-patches/
diff --git a/README.md b/README.md
index 4ece4cf..0d0345b 100644
--- a/README.md
+++ b/README.md
@@ -35,12 +35,12 @@ The product is built around three promises:
-| **266** universities | **1,234** programs | **356** scholarships | **62** cities |
+| **266** universities | **1,233** programs | **351** scholarships | **62** cities |
|:---:|:---:|:---:|:---:|
-The public catalogue also contains **256 published admission-cycle records** and is backed by **2,070 registered official source records**. Snapshot evaluated for **2026-08-10** with `npm run quality:platform-scorecard`.
+The public catalogue contains **52 published cycle or fee-reference records** and is backed by **2,070 registered official source records**. A published record is not automatically an open application window; the decision-oriented metrics below are evaluated for **2026-08-16** with `npm run quality:platform-scorecard`.
Open the honest data-depth scorecard
@@ -50,12 +50,15 @@ Record count is not the same as record completeness. These are the current depth
| Quality indicator | Current baseline | Next gate |
|---|---:|---:|
| Universities below three published programs | **8** | 0 |
-| Programs with a current public cycle | 253 / 1,234 · **20.50%** | ≥ 70% |
-| Programs with duration | **61.59%** | ≥ 90% |
-| Programs with an official application route | **50.89%** | ≥ 80% |
-| Programs with known teaching language | **85.09%** | ≥ 95% |
-| Programs with eligibility/language evidence | **6.00%** | ≥ 50% |
-| Universities connected to scholarships | 207 / 266 | ≥ 230 |
+| Verified international-program identities | 1,233 / 1,233 · **100%** | Maintain 100% |
+| Programs with a fresh 30-day disposition | 49 / 1,233 · **3.97%** | ≥ 70% |
+| Programs with dated or rolling admissions | 49 / 1,233 · **3.97%** | Report honestly |
+| Programs open or upcoming on the evaluation date | 10 / 1,233 · **0.81%** | Report honestly |
+| Programs with duration | **61.64%** | ≥ 90% |
+| Programs with an official application route | **50.85%** | ≥ 80% |
+| Programs with known teaching language | **85.08%** | ≥ 95% |
+| Programs with eligibility/language evidence | **6.16%** | ≥ 50% |
+| Universities connected to scholarships | 204 / 266 | ≥ 230 |
| Cities with reviewed coordinates | 27 / 62 | 62 / 62 |
| Source Manifests registered | 10 / 266 | 266 / 266 |
| Completed V2 Source Manifests | 0 / 266 | 266 / 266 |
@@ -66,6 +69,20 @@ The raw compatibility dataset contains 272 universities, 1,255 programs and 384
+## Current trust-platform milestone
+
+The current release moves the project from a large static directory toward a measurable decision platform:
+
+- quality reporting now separates verified identity, fresh disposition, dated or rolling admissions, and active or upcoming applications;
+- the program explorer exposes “Open now” and “Upcoming” as first-level routes, while language switching preserves semantic filters and resets release-bound cursors;
+- Favorites loads only requested IDs through a four-program comparison projection instead of serializing the entire catalogue to the browser;
+- Release metadata distinguishes raw and public counts, the data-check date, evaluation date, activation time, backend and Vercel deployment SHA;
+- ten pilot universities now use strict Source Manifest V2 ledgers in `in_progress` state—none is mislabeled as fully reconciled;
+- daily and monthly `raw-v1` backups for both D1 databases were uploaded to private R2, read back byte-for-byte and restored into isolated local databases in **101.198 seconds** with zero foreign-key violations;
+- Catalog remains on the JSON compatibility backend while D1 Shadow parity, credentials and rollback evidence are completed.
+
+This milestone deliberately exposes the freshness gap: a fact becoming stale reduces the public count instead of silently remaining visible. That makes the lower current figures a trustworthy operational signal, not a regression hidden by optimistic counting.
+
## What applicants can do
- Browse universities, programs, scholarships and student cities in one coherent interface.
@@ -273,7 +290,7 @@ See [`docs/platform-rollout.md`](./docs/platform-rollout.md), [`docs/backup-and-
```mermaid
flowchart LR
- N["Now
266 public universities"] --> D["Data depth
70% current-cycle coverage"]
+ N["Now
266 public universities"] --> D["Data depth
70% fresh dispositions"]
D --> T["Trust coverage
266 manifests + reconciliations"]
T --> C["D1 cutover
3 releases / 72h shadow parity"]
C --> F["500 universities
after 2 healthy monthly cycles"]
@@ -283,9 +300,10 @@ flowchart LR
Near-term work is measured by:
- raising the remaining 8 sparse universities to 3–5 verified international-student programs or a documented `limited` reconciliation;
-- increasing current-cycle coverage from 20.50% to at least 70%;
-- reaching 90% duration, 80% official application-route and 95% teaching-language coverage;
-- expanding scholarship-connected institutions from 207 to at least 230;
+- increasing fresh-disposition coverage from 3.97% to at least 70%, without counting date-free fee references as application cycles;
+- reaching the six-week gates of 75% duration, 65% official application-route, 90% teaching-language and 25% requirements coverage;
+- continuing after that toward the expansion gates of 90% duration, 80% application-route, 95% teaching-language and 50% requirements coverage;
+- expanding scholarship-connected institutions from 204 to at least 230;
- completing 266 Source Manifests and 266 catalogue reconciliations;
- completing three matching shadow releases over at least 72 hours before Production switches to D1;
- passing two full monthly update cycles before expansion to 500, then 1,000+ institutions.
diff --git a/content/data/admission-cycles.json b/content/data/admission-cycles.json
index fe4fe01..1cabfff 100644
--- a/content/data/admission-cycles.json
+++ b/content/data/admission-cycles.json
@@ -737,9 +737,9 @@
"sourceIds": [
"src-program-review-88495cf206e1"
],
- "verifiedAt": "2026-08-02",
- "reviewAfter": "2026-08-09",
- "status": "stale",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified",
"id": "cycle-2026-a6e5661b86ff",
"programId": "program-soochow-university-international-chinese-language-teachers-scholarship-o",
"academicYear": "2026-2027",
@@ -1745,9 +1745,9 @@
"sourceIds": [
"src-shnu-iclt-2026"
],
- "verifiedAt": "2026-07-28",
- "reviewAfter": "2026-08-04",
- "status": "stale",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified",
"id": "cycle-2027-shnu-iclt-one-semester-spring",
"programId": "program-shanghai-normal-university-iclt-one-semester-language",
"academicYear": "2026-2027",
@@ -1955,9 +1955,9 @@
"sourceIds": [
"src-wku-international-admissions-2027"
],
- "verifiedAt": "2026-07-28",
- "reviewAfter": "2026-08-04",
- "status": "stale",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified",
"id": "cycle-2027-wenzhou-kean-university-finance-bs-spring-transfer",
"programId": "program-wenzhou-kean-university-finance-bs",
"academicYear": "2026-2027",
@@ -1981,9 +1981,9 @@
"sourceIds": [
"src-wku-international-admissions-2027"
],
- "verifiedAt": "2026-07-28",
- "reviewAfter": "2026-08-04",
- "status": "stale",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified",
"id": "cycle-2027-wenzhou-kean-university-global-business-bs-spring-transfer",
"programId": "program-wenzhou-kean-university-global-business-bs",
"academicYear": "2026-2027",
@@ -2007,9 +2007,9 @@
"sourceIds": [
"src-wku-international-admissions-2027"
],
- "verifiedAt": "2026-07-28",
- "reviewAfter": "2026-08-04",
- "status": "stale",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified",
"id": "cycle-2027-wenzhou-kean-university-computer-science-bs-spring-transfer",
"programId": "program-wenzhou-kean-university-computer-science-bs",
"academicYear": "2026-2027",
@@ -2033,9 +2033,9 @@
"sourceIds": [
"src-wku-international-admissions-2027"
],
- "verifiedAt": "2026-07-28",
- "reviewAfter": "2026-08-04",
- "status": "stale",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified",
"id": "cycle-2027-wenzhou-kean-university-biology-cell-molecular-bs-spring-transfer",
"programId": "program-wenzhou-kean-university-biology-cell-molecular-bs",
"academicYear": "2026-2027",
@@ -2059,9 +2059,9 @@
"sourceIds": [
"src-wku-international-admissions-2027"
],
- "verifiedAt": "2026-07-28",
- "reviewAfter": "2026-08-04",
- "status": "stale",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified",
"id": "cycle-2027-wenzhou-kean-university-architecture-bfa-spring-transfer",
"programId": "program-wenzhou-kean-university-architecture-bfa",
"academicYear": "2026-2027",
@@ -3201,9 +3201,9 @@
"sourceIds": [
"src-schwarzman-application-2027"
],
- "verifiedAt": "2026-08-07",
- "reviewAfter": "2026-08-10",
- "status": "verified"
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-13",
+ "status": "stale"
},
{
"id": "cycle-gdut-chinese-language-training-rolling",
@@ -3224,7 +3224,7 @@
],
"verifiedAt": "2026-08-07",
"reviewAfter": "2026-08-14",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-ahmu-m-anesthesiology-2026-2027-other",
@@ -3324,7 +3324,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-ahu-finance-bachelor-2026-2027-other-fee-reference",
@@ -3346,7 +3346,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-ahu-international-chinese-education-bachelor-2026-2027-other-fee-reference",
@@ -3368,7 +3368,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-ahu-software-engineering-bachelor-2026-2027-other-fee-reference",
@@ -3390,7 +3390,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-chinese-degree-bfsu-international-chinese-education-bachelor-2026-2027-autumn",
@@ -3720,7 +3720,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-bisu-chinese-language-bachelor-2025-2026-spring-fee-reference",
@@ -3742,7 +3742,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-bisu-international-chinese-education-master-2026-2027-other-fee-reference",
@@ -3764,7 +3764,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-bisu-chinese-language-nondegree-2025-2026-spring-fee-reference",
@@ -3785,7 +3785,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-bjtu-logistics-engineering-master-en-2026-2027-other-fee-reference",
@@ -3806,7 +3806,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-bjtu-rail-transit-signal-bachelor-en-2026-2027-other-fee-reference",
@@ -3827,7 +3827,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-bjtu-transportation-planning-doctorate-2026-2027-other-fee-reference",
@@ -3848,7 +3848,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-chinese-degree-blcu-international-chinese-education-doctorate-2026-2027-autumn",
@@ -3978,7 +3978,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-bsu-international-chinese-education-bachelor-2026-2027-autumn-fee-reference",
@@ -3999,7 +3999,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-bsu-international-chinese-education-master-2026-2027-autumn",
@@ -4026,7 +4026,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-bsu-wushu-training-2026-2027-autumn",
@@ -4052,7 +4052,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-btbu-business-administration-bachelor-2026-2027-autumn",
@@ -4078,7 +4078,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-btbu-finance-bachelor-2026-2027-autumn",
@@ -4104,7 +4104,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-btbu-international-economics-trade-bachelor-2026-2027-autumn",
@@ -4130,7 +4130,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-btbu-polymer-materials-bachelor-2026-2027-autumn",
@@ -4156,7 +4156,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-2-bwu-international-trade-economics-bachelor-2026-2027-other-fee-reference",
@@ -4250,7 +4250,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-cad-drama-film-doctorate-2026-2027-autumn",
@@ -4277,7 +4277,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-ccnu-chinese-literature-icl-bachelor-2026-2027-autumn",
@@ -4303,7 +4303,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-ccnu-international-chinese-education-master-2026-2027-autumn",
@@ -4329,7 +4329,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ccom-music-master-2026-2027-autumn",
@@ -4355,7 +4355,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ccom-music-doctorate-2026-2027-autumn",
@@ -4381,7 +4381,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ccom-general-visiting-music-2025-2026-spring-fee-reference",
@@ -4402,7 +4402,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ccom-general-visiting-music-2026-2027-autumn-fee-reference",
@@ -4423,7 +4423,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ccom-senior-visiting-music-2025-2026-spring-fee-reference",
@@ -4444,7 +4444,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ccom-senior-visiting-music-2026-2027-autumn-fee-reference",
@@ -4465,7 +4465,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-csu-computing-science-bachelor-2026-2027-other-fee-reference",
@@ -4486,7 +4486,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-csu-mechanical-engineering-bachelor-2026-2027-other-fee-reference",
@@ -4507,7 +4507,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave3-cust-b-software-engineering-en-2026-2027-other-fee-reference",
@@ -4550,7 +4550,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-2-cuit-atmospheric-science-master-2026-2027-autumn-fee-reference",
@@ -4635,7 +4635,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-cuit-social-work-master-2026-2027-autumn-fee-reference",
@@ -4656,7 +4656,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave4-cdut-b-computer-science-and-technology-2026-2027-autumn-fee-reference",
@@ -4762,7 +4762,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-caa-product-design-bachelor-2026-2027-autumn-fee-reference",
@@ -4784,7 +4784,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-ccmusic-composition-bachelor-2026-2027-other-fee-reference",
@@ -4805,7 +4805,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-ccmusic-music-education-vocal-bachelor-2026-2027-other-fee-reference",
@@ -4826,7 +4826,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-ccmusic-conducting-bachelor-2026-2027-other-fee-reference",
@@ -4847,7 +4847,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-cfau-international-economics-trade-bachelor-2026-2027-autumn",
@@ -4873,7 +4873,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-cfau-international-law-bachelor-2026-2027-autumn",
@@ -4899,7 +4899,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-remaining-cjlu-b-business-administration-en-2026-2027-other",
@@ -5050,7 +5050,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-cumt-chinese-one-year-2026-2027-autumn-fee-reference",
@@ -5071,7 +5071,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-cumt-safety-science-master-2026-2027-autumn-fee-reference",
@@ -5094,7 +5094,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave5-depth-cumtb-artificial-intelligence-bachelor-2026-2027-other-fee-reference",
@@ -5250,7 +5250,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-upc-chinese-language-semester-2026-2027-autumn-fee-reference",
@@ -5272,7 +5272,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-upc-petroleum-engineering-bachelor-en-2026-2027-autumn-fee-reference",
@@ -5295,7 +5295,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-upc-foundation-semester-2026-2027-autumn-fee-reference",
@@ -5317,7 +5317,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-cupl-chinese-language-2025-2026-autumn",
@@ -5343,7 +5343,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-cupl-law-bachelor-2026-2027-autumn",
@@ -5369,7 +5369,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave4-cqmu-b-medical-imaging-2026-2027-autumn",
@@ -5604,7 +5604,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-cuz-digital-media-technology-bachelor-2026-2027-autumn",
@@ -5630,7 +5630,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-cuz-iclt-master-2026-2027-autumn",
@@ -5657,7 +5657,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-2-cuz-international-journalism-communication-bachelor-2026-2027-autumn-fee-reference",
@@ -5721,7 +5721,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-breadth-dlmu-foundation-college-preparatory-2026-2027-autumn-fee-reference",
@@ -5743,7 +5743,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-breadth-dlmu-foundation-college-preparatory-2026-2027-other",
@@ -5770,7 +5770,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-dlmu-marine-engineering-bachelor-2026-2027-other-fee-reference",
@@ -5792,7 +5792,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-dlmu-nautical-science-bachelor-2026-2027-other-fee-reference",
@@ -5814,7 +5814,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-dmu-b-clinical-medicine-cn-2026-2027-other-fee-reference",
@@ -5993,7 +5993,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-dufe-chinese-language-2026-2027-spring",
@@ -6138,7 +6138,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-dgut-integrated-circuit-engineering-master-2026-2027-autumn",
@@ -6165,7 +6165,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave3-dgut-international-business-master-2026-2027-autumn-fee-reference",
@@ -6215,7 +6215,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave3-dgut-software-engineering-master-2026-2027-autumn-fee-reference",
@@ -6420,7 +6420,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-ecupl-chinese-language-training-2026-2027-other-fee-reference",
@@ -6467,7 +6467,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-ecupl-international-commercial-law-bachelor-2026-2027-autumn",
@@ -6519,7 +6519,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-ecust-business-chinese-2026-2027-autumn",
@@ -6545,7 +6545,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-ecust-intensive-chinese-2026-2027-autumn",
@@ -6571,7 +6571,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-ecust-standard-chinese-2026-2027-autumn",
@@ -6597,7 +6597,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-chinese-degree-fudan-chinese-language-foreign-bachelor-2026-2027-autumn",
@@ -6737,7 +6737,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-fzu-electrical-theory-new-technology-doctorate-2026-2027-autumn-fee-reference",
@@ -6758,7 +6758,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-fzu-geotechnical-engineering-master-2026-2027-autumn-fee-reference",
@@ -6779,7 +6779,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-fzu-municipal-engineering-master-2026-2027-autumn-fee-reference",
@@ -6800,7 +6800,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-0805-scw-gdou-aquaculture-bachelor-2026-2027-other-fee-reference",
@@ -6821,7 +6821,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-0805-scw-gdou-tcsol-bachelor-2026-2027-other-fee-reference",
@@ -6842,7 +6842,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-gdufe-digital-economy-master-2026-2027-autumn-fee-reference",
@@ -6864,7 +6864,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-gdufe-b-international-business-en-2026-2027-other",
@@ -6913,7 +6913,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-gdufe-b-international-economics-trade-en-2026-2027-other",
@@ -6962,7 +6962,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-gdufe-international-trade-doctorate-2026-2027-autumn-fee-reference",
@@ -6984,7 +6984,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-gdufs-chinese-thai-translation-bachelor-2026-2027-other-fee-reference",
@@ -7219,7 +7219,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-gxnu-chinese-language-literature-doctorate-2026-2027-autumn-fee-reference",
@@ -7240,7 +7240,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-gxnu-computer-science-technology-master-2026-2027-autumn-fee-reference",
@@ -7261,7 +7261,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-gxu-chinese-language-major-bachelor-2026-2027-other-fee-reference",
@@ -7282,7 +7282,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-gxu-chinese-language-student-2026-2027-other-fee-reference",
@@ -7303,7 +7303,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave3-gxtcmu-acupuncture-tuina-bachelor-2026-2027-autumn",
@@ -7428,7 +7428,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-gafa-chinese-painting-bachelor-2026-2027-autumn-fee-reference",
@@ -7449,7 +7449,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-gafa-visual-communication-bachelor-2026-2027-autumn-fee-reference",
@@ -7470,7 +7470,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-gzucm-b-tcm-cn-2026-2027-other-fee-reference",
@@ -7518,7 +7518,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-glmu-b-clinical-medicine-cn-2026-2027-other",
@@ -7570,7 +7570,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-glmu-medical-laboratory-technology-bachelor-2026-2027-autumn-fee-reference",
@@ -7591,7 +7591,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-glmu-nursing-bachelor-2026-2027-autumn-fee-reference",
@@ -7612,7 +7612,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-glmu-b-pharmacy-cn-2026-2027-other",
@@ -7659,7 +7659,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave3-guet-ai-english-bachelor-2026-2027-other-fee-reference",
@@ -7765,7 +7765,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-0805-scw-glut-cs-bachelor-english-2026-2027-other-fee-reference",
@@ -7787,7 +7787,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-0805-scw-glut-long-chinese-year-2026-2027-other-fee-reference",
@@ -7808,7 +7808,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-0805-scw-glut-tcsol-bachelor-2026-2027-other-fee-reference",
@@ -7830,7 +7830,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-gmu-b-clinical-medicine-cn-2026-2027-other",
@@ -8059,7 +8059,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-hmu-public-health-professional-master-2026-2027-other-fee-reference",
@@ -8080,7 +8080,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-hmu-traditional-chinese-medicine-bachelor-2026-2027-other-fee-reference",
@@ -8101,7 +8101,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-hainnu-international-chinese-education-master-2026-2027-autumn-fee-reference",
@@ -8122,7 +8122,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-hainnu-international-journalism-bachelor-2026-2027-autumn-fee-reference",
@@ -8143,7 +8143,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-hainnu-mechanical-automation-bachelor-2026-2027-autumn-fee-reference",
@@ -8164,7 +8164,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-hainnu-standard-chinese-language-2026-2027-other-fee-reference",
@@ -8185,7 +8185,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-hainnu-tcsol-bachelor-2026-2027-autumn-fee-reference",
@@ -8206,7 +8206,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-clw-sw-hainanu-chinese-culture-semester-2026-2027-spring",
@@ -8624,7 +8624,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-hebtu-international-chinese-education-doctorate-2026-2027-autumn-fee-reference",
@@ -8646,7 +8646,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-hebtu-international-chinese-education-master-2026-2027-autumn-fee-reference",
@@ -8668,7 +8668,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-hebut-business-administration-bachelor-2026-2027-autumn-fee-reference",
@@ -8689,7 +8689,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-hebut-electrical-engineering-doctorate-2026-2027-autumn-fee-reference",
@@ -8710,7 +8710,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-hebut-electronic-science-technology-bachelor-2026-2027-autumn-fee-reference",
@@ -8731,7 +8731,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-hebut-international-chinese-education-bachelor-2026-2027-autumn-fee-reference",
@@ -8752,7 +8752,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-hfut-biomedical-engineering-bachelor-2026-2027-autumn-fee-reference",
@@ -8773,7 +8773,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-hfut-civil-engineering-bachelor-2026-2027-autumn-fee-reference",
@@ -8794,7 +8794,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-hfut-international-economics-bachelor-2026-2027-autumn-fee-reference",
@@ -8815,7 +8815,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave4-fastpack-hlju-chinese-language-literature-bachelor-2026-2027-autumn-fee-reference",
@@ -8886,7 +8886,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-henu-mathematics-doctorate-2026-2027-autumn",
@@ -8913,7 +8913,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-henu-software-engineering-master-2026-2027-autumn",
@@ -8940,7 +8940,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-henu-traditional-chinese-sports-doctorate-2026-2027-autumn",
@@ -8967,7 +8967,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave3-haut-m-applied-economics-2026-2027-autumn",
@@ -9422,7 +9422,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-imu-chinese-one-semester-spring-2026-2027-spring",
@@ -9448,7 +9448,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-just-b-business-administration-en-2026-2027-other",
@@ -9521,7 +9521,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-just-management-science-doctorate-2026-2027-autumn-fee-reference",
@@ -9542,7 +9542,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-just-mba-master-2026-2027-autumn-fee-reference",
@@ -9563,7 +9563,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-just-naval-architecture-master-2026-2027-autumn-fee-reference",
@@ -9584,7 +9584,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-jlu-chinese-language-bachelor-2026-2027-autumn-fee-reference",
@@ -9989,7 +9989,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-lnnu-chinese-language-study-2026-2027-other-fee-reference",
@@ -10010,7 +10010,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-lnnu-information-management-systems-bachelor-2026-2027-autumn-fee-reference",
@@ -10031,7 +10031,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-lnnu-international-business-bachelor-2026-2027-autumn-fee-reference",
@@ -10052,7 +10052,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-lnnu-international-chinese-education-master-2026-2027-other-fee-reference",
@@ -10073,7 +10073,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave4-depth-lnu-finance-master-english-2026-2027-autumn-fee-reference",
@@ -10211,7 +10211,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-ncu-chinese-language-training-2026-2027-other-fee-reference",
@@ -10232,7 +10232,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-njau-food-science-bachelor-2026-2027-autumn",
@@ -10258,7 +10258,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-njau-seed-science-bachelor-2026-2027-autumn",
@@ -10284,7 +10284,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-njfu-forest-genetics-master-2026-2027-autumn-fee-reference",
@@ -10305,7 +10305,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-njfu-wood-science-bachelor-2026-2027-autumn-fee-reference",
@@ -10326,7 +10326,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-njmu-clinical-medicine-chinese-bachelor-2026-2027-autumn-fee-reference",
@@ -10347,7 +10347,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-njtech-chemical-engineering-doctorate-2026-2027-other-fee-reference",
@@ -10369,7 +10369,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-njtech-chemical-engineering-bachelor-2026-2027-other-fee-reference",
@@ -10392,7 +10392,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-njtech-iclt-bachelor-2026-2027-other-fee-reference",
@@ -10414,7 +10414,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-njtech-pharmacology-master-2026-2027-other-fee-reference",
@@ -10436,7 +10436,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-nju-2026-bachelor-tcsol-2026-2027-autumn",
@@ -10514,7 +10514,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-nuaa-software-engineering-master-2026-2027-autumn",
@@ -10540,7 +10540,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-nuaa-transportation-engineering-master-2026-2027-autumn",
@@ -10566,7 +10566,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-njucm-materia-medica-pharmacy-bachelor-2026-2027-autumn-fee-reference",
@@ -10587,7 +10587,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-njucm-health-preservation-bachelor-2026-2027-autumn-fee-reference",
@@ -10608,7 +10608,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-nufe-accounting-bachelor-2026-2027-autumn-fee-reference",
@@ -10629,7 +10629,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-nufe-applied-economics-master-2026-2027-autumn-fee-reference",
@@ -10671,7 +10671,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-nufe-business-administration-bachelor-2026-2027-autumn-fee-reference",
@@ -10713,7 +10713,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-nufe-chinese-literature-bachelor-2026-2027-autumn-fee-reference",
@@ -10734,7 +10734,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-nuist-chinese-literature-bachelor-2026-2027-autumn",
@@ -10760,7 +10760,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-nuist-iclt-semester-2026-2027-autumn",
@@ -10786,7 +10786,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-nuist-iclt-semester-2026-2027-spring",
@@ -10812,7 +10812,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-nuist-iclt-one-year-2026-2027-autumn",
@@ -10838,7 +10838,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-nuist-iclt-master-2026-2027-autumn",
@@ -10864,7 +10864,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-njupt-bba-bachelor-2026-2027-autumn-fee-reference",
@@ -10887,7 +10887,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-njupt-business-administration-master-2026-2027-autumn-fee-reference",
@@ -10910,7 +10910,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-ntu-chinese-semester-2026-2027-other-fee-reference",
@@ -10932,7 +10932,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-ntu-b-mbbs-en-2026-2027-other-fee-reference",
@@ -10995,7 +10995,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-ntu-iclt-bachelor-2026-2027-other-fee-reference",
@@ -11016,7 +11016,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecs-ntu-pharmacy-bachelor-2026-2027-other-fee-reference",
@@ -11037,7 +11037,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-remaining-nbu-nondegree-chinese-language-2026-2027-other",
@@ -11083,7 +11083,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-nxmu-clinical-medicine-master-2026-2027-other-fee-reference",
@@ -11104,7 +11104,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-nxmu-mbbs-english-bachelor-2026-2027-other-fee-reference",
@@ -11125,7 +11125,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ncepu-chinese-language-2026-2027-autumn-fee-reference",
@@ -11146,7 +11146,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ncepu-electrical-engineering-doctorate-2026-2027-autumn-fee-reference",
@@ -11167,7 +11167,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ncepu-new-energy-science-engineering-bachelor-2026-2027-autumn-fee-reference",
@@ -11188,7 +11188,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-ncepu-power-engineering-thermophysics-master-2026-2027-autumn-fee-reference",
@@ -11209,7 +11209,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-ncut-b-architecture-en-2026-2027-other",
@@ -11313,7 +11313,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-neau-food-science-doctorate-2026-2027-autumn",
@@ -11339,7 +11339,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-neau-international-chinese-education-master-2026-2027-autumn",
@@ -11366,7 +11366,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-neau-tcsol-bachelor-2026-2027-autumn-fee-reference",
@@ -11387,7 +11387,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-neepu-computer-science-bachelor-2026-2027-autumn-fee-reference",
@@ -11408,7 +11408,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-neepu-electrical-engineering-bachelor-2026-2027-autumn-fee-reference",
@@ -11429,7 +11429,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-neepu-energy-power-engineering-bachelor-2026-2027-autumn-fee-reference",
@@ -11450,7 +11450,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-nefu-ecology-master-2026-2027-autumn-fee-reference",
@@ -11472,7 +11472,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-nefu-environmental-engineering-master-2026-2027-autumn-fee-reference",
@@ -11494,7 +11494,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-nefu-forestry-bachelor-2026-2027-autumn-fee-reference",
@@ -11515,7 +11515,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-nenu-2026-bachelor-tcsol-2026-2027-autumn",
@@ -11698,7 +11698,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-nwnu-international-chinese-education-master-2026-2027-other-fee-reference",
@@ -11719,7 +11719,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-nwnu-international-chinese-education-doctorate-2026-2027-other-fee-reference",
@@ -11740,7 +11740,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-nwupl-chinese-language-2026-2027-autumn-fee-reference",
@@ -11761,7 +11761,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-2-nwupl-cross-border-ecommerce-bachelor-2026-2027-autumn",
@@ -11808,7 +11808,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nnw-nwupl-law-doctorate-2026-2027-autumn-fee-reference",
@@ -11829,7 +11829,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-2-nwupl-law-bachelor-2026-2027-autumn",
@@ -12335,7 +12335,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-qust-language-chinese-2026-2027-other-fee-reference",
@@ -12378,7 +12378,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave5-west-snnu-curriculum-instruction-doctorate-english-2026-2027-autumn",
@@ -12714,7 +12714,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-sdutcm-materia-medica-doctorate-2026-2027-autumn-fee-reference",
@@ -12735,7 +12735,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-sdutcm-tcm-bachelor-2026-2027-autumn-fee-reference",
@@ -12756,7 +12756,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-shcm-art-theory-doctorate-2026-2027-autumn-fee-reference",
@@ -12777,7 +12777,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-shcm-music-therapy-master-2026-2027-autumn-fee-reference",
@@ -12798,7 +12798,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-lixin-business-administration-bachelor-2026-2027-other-fee-reference",
@@ -12819,7 +12819,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-lixin-chinese-literature-bachelor-2026-2027-other-fee-reference",
@@ -12840,7 +12840,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-lixin-chinese-semester-2026-2027-other-fee-reference",
@@ -12861,7 +12861,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-lixin-finance-bachelor-2026-2027-other-fee-reference",
@@ -12926,7 +12926,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-shmtu-b-marine-navigation-bilingual-2026-2027-other-fee-reference",
@@ -13092,7 +13092,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-shou-food-science-master-2026-2027-autumn",
@@ -13118,7 +13118,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-shou-long-chinese-2026-2027-autumn",
@@ -13144,7 +13144,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-shou-marine-science-doctorate-2026-2027-autumn",
@@ -13170,7 +13170,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-root-sta-acting-bachelor-2026-2027-autumn-fee-reference",
@@ -13191,7 +13191,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-root-sta-chinese-language-semester-2025-2026-spring-fee-reference",
@@ -13212,7 +13212,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-root-sta-dance-chinese-bachelor-2026-2027-autumn-fee-reference",
@@ -13233,7 +13233,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-root-sta-digital-media-art-bachelor-2026-2027-autumn-fee-reference",
@@ -13254,7 +13254,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-root-sta-intercultural-communication-master-2026-2027-autumn",
@@ -13282,7 +13282,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-root-sta-directing-bachelor-2026-2027-autumn-fee-reference",
@@ -13303,7 +13303,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-shu-2026-bachelor-icle-2026-2027-autumn",
@@ -13376,7 +13376,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-suep-electrical-engineering-doctorate-2026-2027-autumn-fee-reference",
@@ -13397,7 +13397,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-2-suep-electrical-engineering-automation-bachelor-2026-2027-autumn-fee-reference",
@@ -13439,7 +13439,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-2-suep-international-economy-trade-bachelor-2026-2027-autumn-fee-reference",
@@ -13481,7 +13481,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-shupl-iclte-bachelor-2026-2027-other-fee-reference",
@@ -13502,7 +13502,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-shupl-international-politics-bachelor-2026-2027-other-fee-reference",
@@ -13523,7 +13523,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-sus-sport-rehabilitation-doctorate-2026-2027-autumn",
@@ -13549,7 +13549,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-ecma-sus-traditional-chinese-sports-master-2026-2027-autumn",
@@ -13575,7 +13575,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-sxu-foundation-2026-2027-autumn-fee-reference",
@@ -13596,7 +13596,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-sxu-chinese-language-2026-2027-autumn",
@@ -13622,7 +13622,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave4-medicine-sxtcm-acupuncture-massage-bachelor-2026-2027-autumn",
@@ -13755,7 +13755,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-synu-international-chinese-education-bachelor-2026-2027-autumn",
@@ -13782,7 +13782,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-synu-international-chinese-education-master-2026-2027-autumn",
@@ -13808,7 +13808,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-synu-one-year-chinese-study-2026-2027-autumn",
@@ -13834,7 +13834,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave4-medicine-syphu-medicinal-chemistry-master-2026-2027-other-fee-reference",
@@ -14159,7 +14159,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-0805-scw-scfai-art-studies-master-2026-2027-other-fee-reference",
@@ -14180,7 +14180,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-0805-scw-scfai-painting-bachelor-2026-2027-other-fee-reference",
@@ -14201,7 +14201,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-sisu-chinese-language-year-2026-2027-other",
@@ -14462,7 +14462,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-suda-long-chinese-year-2026-2027-autumn",
@@ -14476,18 +14476,18 @@
"tuitionPeriod": "academic-year",
"tuitionStatus": "confirmed",
"evidenceBasis": "cycle-specific",
- "factScope": "partial",
- "applicationFeeCny": null,
+ "factScope": "complete",
+ "applicationFeeCny": 500,
"notes": {
- "en": "The official long-term Chinese notice lists the academic-year option and CNY 17,000 annual tuition; the published 2026 application window ends 30 September.",
- "zh": "官方长期汉语通知列出一学年选项和每学年17000元学费,2026年申请窗口截至9月30日。",
- "ru": "Официальная страница указывает годовой вариант, плату 17 000 юаней и срок подачи 30 сентября 2026 года."
+ "en": "The official long-term Chinese notice lists the academic-year option, CNY 17,000 tuition, a CNY 500 application fee and a September 30, 2026 deadline.",
+ "zh": "官方长期汉语通知列出一学年选项、17000元学费、500元报名费及2026年9月30日截止日期。",
+ "ru": "Официальная страница указывает годовой вариант, плату 17 000 юаней, регистрационный сбор 500 юаней и срок 30 сентября 2026 года."
},
"sourceIds": [
"src-gap-program-mve-jzh-suda-long-chinese-year"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-08-10",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
"status": "verified"
},
{
@@ -14502,18 +14502,18 @@
"tuitionPeriod": "semester",
"tuitionStatus": "confirmed",
"evidenceBasis": "cycle-specific",
- "factScope": "partial",
- "applicationFeeCny": null,
+ "factScope": "complete",
+ "applicationFeeCny": 500,
"notes": {
- "en": "The official 2026 notice publishes the semester dates, CNY 8,500 tuition and an application window ending 30 September 2026.",
- "zh": "苏州大学2026年官方通知公布了长期汉语一学期项目、8500元学费及2026年9月30日截止日期。",
- "ru": "Официальное объявление 2026 года подтверждает семестровую программу, плату 8 500 юаней и срок 30 сентября 2026 года."
+ "en": "The official 2026 notice publishes the semester dates, CNY 8,500 tuition, a CNY 500 application fee and a September 30, 2026 deadline.",
+ "zh": "苏州大学2026年官方通知公布长期汉语一学期项目、8500元学费、500元报名费及2026年9月30日截止日期。",
+ "ru": "Официальное объявление 2026 года подтверждает семестровую программу, плату 8 500 юаней, регистрационный сбор 500 юаней и срок 30 сентября 2026 года."
},
"sourceIds": [
"src-gap-program-mve-jzh-suda-long-chinese-semester"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-08-10",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
"status": "verified"
},
{
@@ -14537,7 +14537,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-chinese-degree-scnu-chinese-language-bachelor-2026-2027-autumn-fee-reference",
@@ -14910,7 +14910,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-tyut-foundation-2026-2027-autumn-fee-reference",
@@ -14931,7 +14931,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-tyut-mechanical-engineering-master-2026-2027-autumn-fee-reference",
@@ -14952,7 +14952,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-tyut-teaching-chinese-bachelor-2026-2027-autumn-fee-reference",
@@ -14973,7 +14973,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-tgu-artificial-intelligence-bachelor-2026-2027-autumn",
@@ -14999,7 +14999,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-tgu-chinese-language-bachelor-2026-2027-autumn",
@@ -15025,7 +15025,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-tgu-chinese-language-general-scholar-2026-2027-other-fee-reference",
@@ -15046,7 +15046,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-nss-tgu-performance-model-bachelor-2026-2027-autumn",
@@ -15072,7 +15072,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-tjfsu-chinese-language-autumn-2026-2027-other-fee-reference",
@@ -15458,7 +15458,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-tjutcm-chinese-materia-medica-doctorate-2026-2027-autumn-fee-reference",
@@ -15479,7 +15479,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-tjutcm-international-chinese-education-master-2026-2027-autumn-fee-reference",
@@ -15500,7 +15500,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-prog-mew-ne-tjutcm-chinese-medicine-bachelor-2026-2027-autumn-fee-reference",
@@ -15521,7 +15521,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave6-ucas-biology-master-2026-2027-autumn-fee-reference",
@@ -16256,7 +16256,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-xtu-communication-engineering-bachelor-2026-2027-autumn-fee-reference",
@@ -16278,7 +16278,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-xtu-finance-bachelor-2026-2027-autumn-fee-reference",
@@ -16300,7 +16300,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-csw-xtu-philosophy-bachelor-2026-2027-autumn-fee-reference",
@@ -16322,7 +16322,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-xjau-b-animal-medicine-2026-2027-other",
@@ -16428,7 +16428,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-xjnu-international-chinese-education-bachelor-2026-2027-autumn",
@@ -16454,7 +16454,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-xjnu-international-chinese-education-master-2026-2027-autumn",
@@ -16480,7 +16480,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-xjnu-linguistics-applied-linguistics-master-2026-2027-autumn",
@@ -16506,7 +16506,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave5-west-xju-computer-science-master-english-2026-2027-autumn-fee-reference",
@@ -16692,7 +16692,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-yangtze-crop-science-master-2026-2027-autumn-fee-reference",
@@ -16713,7 +16713,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mew-scws-yangtze-mechanical-engineering-master-2026-2027-autumn-fee-reference",
@@ -16734,7 +16734,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-yangtzeu-m-oil-gas-well-engineering-en-2026-2027-other-fee-reference",
@@ -16782,7 +16782,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-yzu-biotechnology-bachelor-2026-2027-autumn",
@@ -16810,7 +16810,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-yzu-mechanical-design-bachelor-2026-2027-autumn",
@@ -16838,7 +16838,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-yzu-b-software-engineering-en-2026-2027-other",
@@ -16918,7 +16918,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-wave8-ytu-applied-economics-master-2026-2027-other-fee-reference",
@@ -17385,7 +17385,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-zcmu-b-clinical-medicine-2026-2027-other",
@@ -17432,7 +17432,7 @@
],
"verifiedAt": "2026-08-05",
"reviewAfter": "2026-08-12",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-zjgsu-b-accounting-en-2026-2027-other-fee-reference",
@@ -17766,7 +17766,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-local-strong-zust-b-computer-science-en-2026-2027-other-fee-reference",
@@ -17814,7 +17814,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-mve-jzh-zust-iclt-semester-2026-2027-autumn",
@@ -17831,15 +17831,15 @@
"factScope": "dates-only",
"applicationFeeCny": null,
"notes": {
- "en": "ZUST confirms a five-month semester route. The March 2027 intake remains open until October 31, 2026.",
- "zh": "浙科大确认五个月的一学期研修;2027年3月入学申请截至2026年10月31日。",
- "ru": "ZUST подтверждает пятимесячную программу; набор на март 2027 открыт до 31 октября 2026 года."
+ "en": "ZUST confirms a five-month semester route; the September 2026 intake closed on May 15, 2026. The separate March 2027 cycle is recorded independently.",
+ "zh": "浙科大确认五个月的一学期研修;2026年9月入学申请已于2026年5月15日截止,2027年3月周期另行记录。",
+ "ru": "ZUST подтверждает пятимесячную программу; набор сентября 2026 года закрыт 15 мая 2026 года, а цикл марта 2027 года указан отдельно."
},
"sourceIds": [
"src-gap-program-mve-jzh-zust-iclt-semester"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-08-10",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
"status": "verified"
},
{
@@ -17864,8 +17864,8 @@
"sourceIds": [
"src-gap-program-mve-jzh-zust-iclt-semester"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-08-10",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
"status": "verified"
},
{
@@ -17912,8 +17912,8 @@
"sourceIds": [
"src-gap-program-mve-jzh-zust-iclt-master"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-08-10",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
"status": "verified"
},
{
@@ -17936,7 +17936,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-zuel-chinese-language-2026-2027-other-fee-reference",
@@ -17957,7 +17957,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-zuel-international-law-english-master-2026-2027-other-fee-reference",
@@ -17978,7 +17978,7 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
},
{
"id": "cycle-gap-sparse-depth-0808-zuel-imba-master-2026-2027-other-fee-reference",
@@ -17999,6 +17999,6 @@
],
"verifiedAt": "2026-08-08",
"reviewAfter": "2026-08-15",
- "status": "verified"
+ "status": "stale"
}
]
diff --git a/content/data/programs.json b/content/data/programs.json
index 7675199..f2e14b3 100644
--- a/content/data/programs.json
+++ b/content/data/programs.json
@@ -2675,8 +2675,8 @@
"sourceIds": [
"src-program-review-88495cf206e1"
],
- "verifiedAt": "2026-07-26",
- "reviewAfter": "2026-08-25",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-09-09",
"status": "verified",
"id": "program-soochow-university-international-chinese-language-teachers-scholarship-o",
"slug": "soochow-university-international-chinese-language-teachers-scholarship-o",
@@ -2691,11 +2691,16 @@
"teachingLanguages": [
"Chinese"
],
- "durationMonths": null,
+ "durationMonths": 5,
"programUrl": "https://oversea.suda.edu.cn/oversea_en/bb/fd/c11345a441341/page.htm",
"applyUrl": "https://oversea.suda.edu.cn/oversea_en/11341/list.htm",
- "languageRequirements": [],
- "verificationScope": "identity"
+ "languageRequirements": [
+ {
+ "test": "HSK",
+ "minimum": "HSK Level 3: 180; an HSKK score is also required for the International Chinese Language Education / Chinese Language and Literature one-semester track."
+ }
+ ],
+ "verificationScope": "facts"
},
{
"sourceIds": [
@@ -12059,7 +12064,7 @@
"languageRequirements": [
{
"test": "other",
- "minimum": "Official English proficiency evidence is required unless the applicant qualifies for the English-medium study exemption."
+ "minimum": "TOEFL iBT 100 on the 120-point scale (or 5 on the 6-point scale), IELTS 7, Duolingo 130, or Cambridge C1/C2 185; waived for native English speakers or applicants with at least two years in an English-speaking post-secondary program."
}
],
"verificationScope": "complete",
@@ -12121,9 +12126,9 @@
"ru": "Онлайн-заявка и академические документы"
},
{
- "en": "Essays, recommendations and required video",
- "zh": "申请文书、推荐信及规定视频",
- "ru": "Эссе, рекомендации и обязательное видео"
+ "en": "Essays and three recommendations; the one-minute video is highly recommended, not required.",
+ "zh": "申请文书与三封推荐信;一分钟视频为强烈建议提交,并非必交。",
+ "ru": "Эссе и три рекомендации; минутное видео настоятельно рекомендуется, но не является обязательным."
}
],
"campus": {
@@ -12137,9 +12142,9 @@
"src-schwarzman-application-2027",
"src-thu-graduate-programs-in-english-current"
],
- "verifiedAt": "2026-07-29",
- "reviewAfter": "2026-08-28",
- "status": "verified"
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-13",
+ "status": "stale"
},
{
"id": "program-guangdong-university-of-technology-e-commerce-bachelor",
@@ -34719,13 +34724,18 @@
"durationMonths": 5,
"programUrl": "https://ies.zust.edu.cn/info/1271/4219.htm",
"applyUrl": "https://isam.zust.edu.cn/",
- "languageRequirements": [],
+ "languageRequirements": [
+ {
+ "test": "HSK",
+ "minimum": "HSK Level 3: 180; an HSKK score is also required for the International Chinese Language Education / Chinese Language and Literature one-semester track."
+ }
+ ],
"verificationScope": "facts",
"sourceIds": [
"src-gap-program-mve-jzh-zust-iclt-semester"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-09-02",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-09-09",
"status": "verified"
},
{
@@ -34772,13 +34782,18 @@
"durationMonths": 24,
"programUrl": "https://ies.zust.edu.cn/info/1271/4219.htm",
"applyUrl": "https://isam.zust.edu.cn/",
- "languageRequirements": [],
+ "languageRequirements": [
+ {
+ "test": "HSK",
+ "minimum": "HSK Level 5: 210 and HSKK Intermediate: 60."
+ }
+ ],
"verificationScope": "facts",
"sourceIds": [
"src-gap-program-mve-jzh-zust-iclt-master"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-09-02",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-09-09",
"status": "verified"
},
{
diff --git a/content/data/scholarships.json b/content/data/scholarships.json
index 37bf6fc..09d526f 100644
--- a/content/data/scholarships.json
+++ b/content/data/scholarships.json
@@ -2513,9 +2513,9 @@
"src-schwarzman-program-current",
"src-schwarzman-application-2027"
],
- "verifiedAt": "2026-08-07",
- "reviewAfter": "2026-08-10",
- "status": "verified"
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-13",
+ "status": "stale"
},
{
"id": "scholarship-jnu-guangdong-government-international-students",
@@ -3389,7 +3389,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "sch-gap-sparse-depth-0808-csu-university-scholarship",
@@ -4677,7 +4677,7 @@
],
"verifiedAt": "2026-08-04",
"reviewAfter": "2026-08-11",
- "status": "verified"
+ "status": "stale"
},
{
"id": "sch-gap-local-dufe-iclts",
@@ -6312,8 +6312,8 @@
"sourceIds": [
"src-gap-scholarship-mew-scws-hainnu-iclt-scholarship-2026"
],
- "verifiedAt": "2026-08-04",
- "reviewAfter": "2026-08-11",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
"status": "verified"
},
{
@@ -7250,7 +7250,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "sch-gap-wave8-jsu-presidential-scholarship",
@@ -8219,7 +8219,7 @@
],
"verifiedAt": "2026-08-03",
"reviewAfter": "2026-08-10",
- "status": "verified"
+ "status": "stale"
},
{
"id": "sch-gap-mve-jzh-njupt-csc-high-level-2026",
@@ -9174,9 +9174,9 @@
"src-gap-scholarship-pku-depth-international-chinese-language-teachers-scholarship",
"src-gap-scholarship-pku-depth-international-chinese-language-teachers-scholarship-support-1"
],
- "verifiedAt": "2026-07-30",
- "reviewAfter": "2026-08-06",
- "status": "stale"
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified"
},
{
"id": "sch-gap-pku-depth-law-international-student-scholarship",
@@ -10430,9 +10430,9 @@
"sourceIds": [
"src-gap-scholarship-sch-mew-nss-synu-iclts"
],
- "verifiedAt": "2026-08-04",
- "reviewAfter": "2026-08-11",
- "status": "verified"
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-13",
+ "status": "stale"
},
{
"id": "sch-gap-wave3-sztu-guangdong-government-scholarship",
@@ -10889,9 +10889,9 @@
"sourceIds": [
"src-gap-scholarship-sch-sisu-iclt-2026"
],
- "verifiedAt": "2026-08-02",
- "reviewAfter": "2026-08-09",
- "status": "stale"
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
+ "status": "verified"
},
{
"id": "sch-gap-sch-sisu-moe-chongqing-joint-2026",
@@ -11008,26 +11008,28 @@
"uni-soochow-university"
],
"programIds": [
- "prog-gap-mve-jzh-suda-iclt-one-year"
+ "prog-gap-mve-jzh-suda-iclt-one-year",
+ "program-soochow-university-international-chinese-language-teachers-scholarship-o"
],
"coverage": {
- "tuition": "unknown",
- "accommodation": "unknown",
- "insurance": "unknown",
+ "tuition": "full",
+ "accommodation": "full",
+ "insurance": true,
"stipendCnyPerMonth": null
},
"deadline": "2026-10-31",
"applicationUrl": "https://oversea.suda.edu.cn/oversea_en/bb/fd/c11345a441341/page.htm",
"summary": {
- "en": "The official 2026 guide confirms Soochow University as a host, closes the September one-year route on May 15, and keeps the March 2027 one-semester route open until October 31, 2026; funding details were not promoted without direct page evidence.",
- "zh": "2026年官方指南确认苏州大学为接收院校:9月一学年项目已于5月15日截止,2027年3月一学期项目开放至2026年10月31日;未直接显示的资助细节不作推断。",
- "ru": "Официальное руководство 2026 подтверждает Университет Сучжоу: годичный набор сентября закрыт 15 мая, а семестровый набор марта 2027 открыт до 31 октября 2026 года; неподтверждённые детали финансирования не выводятся."
+ "en": "Soochow University's 2026 guide keeps the March 2027 one-semester route open until October 31, 2026. The official CLEC guide confirms tuition, on-campus accommodation, living allowance and medical insurance coverage; allowance rates vary by category.",
+ "zh": "苏州大学2026年指南显示,2027年3月一学期项目开放至2026年10月31日。中外语言交流合作中心官方办法确认资助包含学费、校内住宿、生活费和综合医疗保险,生活费标准按项目类别区分。",
+ "ru": "Руководство Университета Сучжоу оставляет набор на март 2027 года открытым до 31 октября 2026 года. Правила CLEC подтверждают покрытие обучения, проживания, пособия и медицинской страховки; размер пособия зависит от категории."
},
"sourceIds": [
- "src-gap-scholarship-mve-jzh-suda-iclt-scholarship"
+ "src-gap-scholarship-mve-jzh-suda-iclt-scholarship",
+ "src-gov-clec"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-08-10",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
"status": "verified"
},
{
@@ -11087,16 +11089,16 @@
"deadline": "2026-09-01",
"applicationUrl": "https://gdic.scau.edu.cn/2026/0421/c11155a432798/page.htm",
"summary": {
- "en": "SCAU's official notice publishes all three award amounts and a September 1, 2026 deadline, which remains open as checked on August 3.",
- "zh": "华南农业大学官方通知公布本科、硕士、博士三档金额,截止2026年9月1日;截至8月3日仍可申请。",
- "ru": "Официальное уведомление SCAU публикует три размера выплат и срок 1 сентября 2026 года; на 3 августа прием открыт."
+ "en": "SCAU's official notice publishes one-time awards of CNY 10,000 for bachelor's, CNY 20,000 for master's and CNY 30,000 for doctoral students, with a September 1, 2026 deadline.",
+ "zh": "华南农业大学官方通知公布一次性奖励:本科10000元、硕士20000元、博士30000元,截止2026年9月1日。",
+ "ru": "Официальное уведомление SCAU устанавливает разовые выплаты 10 000 юаней бакалаврам, 20 000 магистрантам и 30 000 докторантам; срок — 1 сентября 2026 года."
},
"sourceIds": [
"src-gap-scholarship-mew-csw-scau-guangdong-government-scholarship-2026"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-08-10",
- "status": "verified"
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-13",
+ "status": "stale"
},
{
"id": "sch-gap-chinese-scholarship-scnu-iclt-degree-programs-2026",
@@ -13203,23 +13205,24 @@
"prog-gap-mve-jzh-zust-iclt-semester"
],
"coverage": {
- "tuition": "unknown",
- "accommodation": "unknown",
- "insurance": "unknown",
+ "tuition": "full",
+ "accommodation": "full",
+ "insurance": true,
"stipendCnyPerMonth": null
},
"deadline": "2026-10-31",
"applicationUrl": "https://ies.zust.edu.cn/info/1271/4219.htm",
"summary": {
- "en": "ZUST's official guide confirms the scholarship categories and a future October 31 deadline for March 2027 admission; unquoted funding components remain unset.",
- "zh": "浙科大官方指南确认奖学金类别及2027年3月入学的2026年10月31日截止日期;未直接引述的资助内容暂不填写。",
- "ru": "Официальное руководство ZUST подтверждает категории и срок 31 октября 2026 года для марта 2027; неподтверждённое финансирование не указано."
+ "en": "ZUST's official guide confirms the categories and October 31, 2026 deadline for March 2027 admission. The official CLEC guide confirms tuition, on-campus accommodation, living allowance and medical insurance coverage; allowance rates vary by category.",
+ "zh": "浙科大官方指南确认项目类别及2027年3月入学的2026年10月31日截止日期。中外语言交流合作中心官方办法确认资助包含学费、校内住宿、生活费和综合医疗保险,生活费标准按项目类别区分。",
+ "ru": "Руководство ZUST подтверждает категории и срок 31 октября 2026 года для марта 2027. Правила CLEC подтверждают покрытие обучения, проживания, пособия и медицинской страховки; размер пособия зависит от категории."
},
"sourceIds": [
- "src-gap-scholarship-mve-jzh-zust-iclt-scholarship"
+ "src-gap-scholarship-mve-jzh-zust-iclt-scholarship",
+ "src-gov-clec"
],
- "verifiedAt": "2026-08-03",
- "reviewAfter": "2026-08-10",
+ "verifiedAt": "2026-08-10",
+ "reviewAfter": "2026-08-17",
"status": "verified"
},
{
diff --git a/content/data/sources.json b/content/data/sources.json
index 39af8ff..6f8be44 100644
--- a/content/data/sources.json
+++ b/content/data/sources.json
@@ -537,7 +537,7 @@
"kind": "government",
"language": "other",
"official": true,
- "accessedAt": "2026-07-19"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-program-pku-chinese-language-summer-2026",
@@ -1587,7 +1587,7 @@
"kind": "program",
"language": "other",
"official": true,
- "accessedAt": "2026-08-02"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-program-review-106a1853d635",
@@ -3037,7 +3037,7 @@
"kind": "program",
"language": "zh",
"official": true,
- "accessedAt": "2026-07-28"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-xisu-regular-chinese-language",
@@ -3247,7 +3247,7 @@
"kind": "admissions",
"language": "en",
"official": true,
- "accessedAt": "2026-07-28"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-wku-finance-bs",
@@ -4637,7 +4637,7 @@
"kind": "program",
"language": "en",
"official": true,
- "accessedAt": "2026-07-29"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-thu-advanced-computing-current",
@@ -4677,7 +4677,7 @@
"kind": "program",
"language": "en",
"official": true,
- "accessedAt": "2026-08-07"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-schwarzman-application-2027",
@@ -4687,7 +4687,7 @@
"kind": "admissions",
"language": "en",
"official": true,
- "accessedAt": "2026-08-07"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-uni-guangdong-university-of-technology",
@@ -14477,7 +14477,7 @@
"kind": "program",
"language": "en",
"official": true,
- "accessedAt": "2026-08-03"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-program-mve-jzh-suda-long-chinese-semester",
@@ -14487,7 +14487,7 @@
"kind": "program",
"language": "en",
"official": true,
- "accessedAt": "2026-08-03"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-program-mew-csw-scau-plant-protection-professional-master",
@@ -16987,7 +16987,7 @@
"kind": "program",
"language": "en",
"official": true,
- "accessedAt": "2026-08-03"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-program-local-strong-zust-b-international-economics-trade-en",
@@ -17017,7 +17017,7 @@
"kind": "program",
"language": "en",
"official": true,
- "accessedAt": "2026-08-03"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-program-sparse-depth-0808-zzu-architecture-master",
@@ -18297,7 +18297,7 @@
"kind": "scholarship",
"language": "en",
"official": true,
- "accessedAt": "2026-08-04"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-scholarship-mew-scws-hainnu-hainan-government-scholarship",
@@ -19217,7 +19217,7 @@
"kind": "scholarship",
"language": "en",
"official": true,
- "accessedAt": "2026-07-30"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-scholarship-pku-depth-international-chinese-language-teachers-scholarship-support-1",
@@ -19227,7 +19227,7 @@
"kind": "scholarship",
"language": "en",
"official": true,
- "accessedAt": "2026-07-30"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-scholarship-pku-depth-pku-scholarship-international-students",
@@ -19697,7 +19697,7 @@
"kind": "scholarship",
"language": "zh",
"official": true,
- "accessedAt": "2026-08-04"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-scholarship-wave3-sztu-guangdong-government-scholarship",
@@ -19867,7 +19867,7 @@
"kind": "scholarship",
"language": "zh",
"official": true,
- "accessedAt": "2026-08-02"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-scholarship-sch-sisu-moe-chongqing-joint-2026",
@@ -19907,7 +19907,7 @@
"kind": "scholarship",
"language": "en",
"official": true,
- "accessedAt": "2026-08-03"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-scholarship-mve-jzh-suda-international-scholarship",
@@ -19927,7 +19927,7 @@
"kind": "scholarship",
"language": "en",
"official": true,
- "accessedAt": "2026-08-03"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-scholarship-chinese-scholarship-scnu-iclt-degree-programs-2026",
@@ -20657,7 +20657,7 @@
"kind": "scholarship",
"language": "en",
"official": true,
- "accessedAt": "2026-08-03"
+ "accessedAt": "2026-08-10"
},
{
"id": "src-gap-scholarship-mve-jzh-zust-outstanding-freshman-2026",
diff --git a/content/source-manifests/pilot/fudan-university.json b/content/source-manifests/pilot/fudan-university.json
index ae03eab..881c855 100644
--- a/content/source-manifests/pilot/fudan-university.json
+++ b/content/source-manifests/pilot/fudan-university.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-fudan-university",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["iso.fudan.edu.cn","istudent.fudan.edu.cn"],
"sources": [
{
"version": 1, "id": "fudan-intl-admissions-home", "institutionId": "uni-fudan-university", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -82,5 +84,28 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program pages will be enumerated from the registered catalog anchors in a later discovery crawl." },
{ "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["fudan-admissions-catalog-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "fudan-university-scholarship-index",
+ "officialKey": "sch-gap-chinese-scholarship-fudan-iclt-degree-programs-2026",
+ "officialName": "Fudan 2026 International Chinese Language Teachers Scholarship — Degree Programs",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "fudan-intl-admissions-home",
+ "officialKey": "scholarship-fudan-university-international-student",
+ "officialName": "Fudan University Scholarship for International Students",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 2 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/harbin-institute-of-technology.json b/content/source-manifests/pilot/harbin-institute-of-technology.json
index 4abdb0b..7809011 100644
--- a/content/source-manifests/pilot/harbin-institute-of-technology.json
+++ b/content/source-manifests/pilot/harbin-institute-of-technology.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-harbin-institute-of-technology",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["hit.at0086.cn","studyathit.hit.edu.cn"],
"sources": [
{
"version": 1, "id": "hit-intl-admissions-home", "institutionId": "uni-harbin-institute-of-technology", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -76,5 +78,52 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." },
{ "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["hit-admissions-catalog-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "hit-non-degree-catalog",
+ "officialKey": "prog-gap-wave8-hit-long-term-chinese-language",
+ "officialName": "Long-Term Chinese Language Program",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "hit-masters-catalog",
+ "officialKey": "program-harbin-institute-of-technology-civil-engineering-master",
+ "officialName": "Civil Engineering",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "hit-masters-catalog",
+ "officialKey": "program-harbin-institute-of-technology-mechanical-engineering-master",
+ "officialName": "Mechanical Engineering",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "hit-intl-admissions-home",
+ "officialKey": "scholarship-harbin-institute-of-technology",
+ "officialName": "Harbin Institute of Technology Scholarship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "hit-intl-admissions-home",
+ "officialKey": "scholarship-heilongjiang-government",
+ "officialName": "Heilongjiang Provincial Government Scholarship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 5 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/nanjing-university.json b/content/source-manifests/pilot/nanjing-university.json
index e68ab62..0826680 100644
--- a/content/source-manifests/pilot/nanjing-university.json
+++ b/content/source-manifests/pilot/nanjing-university.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-nanjing-university",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["hwxy.nju.edu.cn","nju.17gz.org"],
"sources": [
{
"version": 1, "id": "nju-intl-admissions-home", "institutionId": "uni-nanjing-university", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -76,5 +78,36 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." },
{ "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["nju-admissions-catalog-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "nju-2026-chinese-government-scholarship",
+ "officialKey": "sch-gap-nju-2026-iclts-degree-coverage",
+ "officialName": "International Chinese Language Teachers Scholarship — NJU",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "nju-intl-admissions-home",
+ "officialKey": "scholarship-jiangsu-government",
+ "officialName": "Jiangsu Government Scholarship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "nju-intl-admissions-home",
+ "officialKey": "scholarship-nanjing-university-international-student",
+ "officialName": "Nanjing University Scholarship for International Students",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 3 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/peking-university.json b/content/source-manifests/pilot/peking-university.json
index 84f97b9..f799964 100644
--- a/content/source-manifests/pilot/peking-university.json
+++ b/content/source-manifests/pilot/peking-university.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-peking-university",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["isd.pku.edu.cn","www.isd.pku.edu.cn","www.studyatpku.com"],
"sources": [
{
"version": 1, "id": "pku-intl-admissions-home", "institutionId": "uni-peking-university", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -94,5 +96,84 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchor in a later crawl." },
{ "sourceCategory": "contacts", "status": "registered", "sourceIds": ["pku-admissions-contacts"] },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["pku-admissions-catalog-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "pku-intl-admissions-home",
+ "officialKey": "program-peking-university-chinese-literature-bachelor",
+ "officialName": "Chinese Language and Literature",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "pku-intl-admissions-home",
+ "officialKey": "program-peking-university-international-relations-master",
+ "officialName": "International Relations",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "pku-2026-university-scholarships",
+ "officialKey": "sch-gap-pku-depth-beijing-government-scholarship",
+ "officialName": "Beijing Government Scholarship for International Students at PKU",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "pku-2026-university-scholarships",
+ "officialKey": "sch-gap-pku-depth-cgs-advanced-graduate",
+ "officialName": "Chinese Government Scholarship — Advanced Graduate Program at PKU",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "pku-2026-university-scholarships",
+ "officialKey": "sch-gap-pku-depth-cs-international-phd-full-support",
+ "officialName": "School of Computer Science International PhD Full Financial Support",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "pku-2026-university-scholarships",
+ "officialKey": "sch-gap-pku-depth-international-chinese-language-teachers-scholarship",
+ "officialName": "International Chinese Language Teachers Scholarship at PKU",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "pku-2026-university-scholarships",
+ "officialKey": "sch-gap-pku-depth-law-international-student-scholarship",
+ "officialName": "PKU Law International Student Scholarship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "pku-2026-government-scholarships",
+ "officialKey": "sch-gap-pku-depth-yenching-fellowship",
+ "officialName": "Yenching Fellowship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "pku-2026-university-scholarships",
+ "officialKey": "scholarship-peking-university-international-student",
+ "officialName": "Peking University Scholarship for International Students",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 9 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/shanghai-jiao-tong-university.json b/content/source-manifests/pilot/shanghai-jiao-tong-university.json
index 8fc93db..1ab9332 100644
--- a/content/source-manifests/pilot/shanghai-jiao-tong-university.json
+++ b/content/source-manifests/pilot/shanghai-jiao-tong-university.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-shanghai-jiao-tong-university",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["apply.sjtu.edu.cn","isc.sjtu.edu.cn"],
"sources": [
{
"version": 1, "id": "sjtu-intl-admissions-home", "institutionId": "uni-shanghai-jiao-tong-university", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -76,5 +78,20 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." },
{ "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["sjtu-admissions-catalog-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "sjtu-admissions-catalog-anchor",
+ "officialKey": "scholarship-shanghai-jiao-tong-university",
+ "officialName": "Shanghai Jiao Tong University Scholarship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 1 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/sun-yat-sen-university.json b/content/source-manifests/pilot/sun-yat-sen-university.json
index ae08ea9..d392286 100644
--- a/content/source-manifests/pilot/sun-yat-sen-university.json
+++ b/content/source-manifests/pilot/sun-yat-sen-university.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-sun-yat-sen-university",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["apply.sysu.edu.cn","iso.sysu.edu.cn"],
"sources": [
{
"version": 1, "id": "sysu-intl-admissions-home", "institutionId": "uni-sun-yat-sen-university", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -82,5 +84,36 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." },
{ "sourceCategory": "contacts", "status": "discovery_pending", "note": "A distinct stable official admissions contact page was not confirmed in this pilot review." },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["sysu-application-guide-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "sysu-2026-non-degree-program",
+ "officialKey": "program-sun-yat-sen-university-2026-one-academic-year-pre-university-program-fou",
+ "officialName": "2026 One-Academic-Year Pre-university Program",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "sysu-2026-non-degree-program",
+ "officialKey": "program-sun-yat-sen-university-one-semester-pre-university-program-foundation",
+ "officialName": "One-Semester Pre-university Program",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "sysu-2026-university-scholarship",
+ "officialKey": "sch-gap-clw-sw-sysu-international-student-scholarship-2026",
+ "officialName": "SYSU Scholarship for International Students 2026",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 3 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/tsinghua-university.json b/content/source-manifests/pilot/tsinghua-university.json
index 4c30581..a9d15fb 100644
--- a/content/source-manifests/pilot/tsinghua-university.json
+++ b/content/source-manifests/pilot/tsinghua-university.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-tsinghua-university",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["apply.join-tsinghua.edu.cn","international.join-tsinghua.edu.cn","yz.tsinghua.edu.cn","yzbm.tsinghua.edu.cn"],
"sources": [
{
"version": 1,
@@ -230,5 +232,28 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors in a later crawl." },
{ "sourceCategory": "contacts", "status": "registered", "sourceIds": ["thu-graduate-admissions-contacts"] },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["thu-graduate-catalog-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "thu-intl-admissions-home",
+ "officialKey": "program-tsinghua-university-business-administration-master",
+ "officialName": "Business Administration",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "thu-intl-admissions-home",
+ "officialKey": "scholarship-tsinghua-university",
+ "officialName": "Tsinghua University Scholarship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 2 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/ustc.json b/content/source-manifests/pilot/ustc.json
index acf356d..307681a 100644
--- a/content/source-manifests/pilot/ustc.json
+++ b/content/source-manifests/pilot/ustc.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-university-of-science-and-technology-of-china",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["ic.ustc.edu.cn","isa.ustc.edu.cn"],
"sources": [
{
"version": 1, "id": "ustc-intl-admissions-home", "institutionId": "uni-university-of-science-and-technology-of-china", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -88,5 +90,36 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." },
{ "sourceCategory": "contacts", "status": "registered", "sourceIds": ["ustc-admissions-contacts"] },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["ustc-admissions-catalog-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "ustc-intl-admissions-home",
+ "officialKey": "program-university-of-science-and-technology-of-china-chemistry-bachelor",
+ "officialName": "Chemistry",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "ustc-intl-admissions-home",
+ "officialKey": "program-university-of-science-and-technology-of-china-physics-bachelor",
+ "officialName": "Physics",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "ustc-government-scholarship",
+ "officialKey": "sch-gap-wave8-ustc-youth-excellence-full-scholarship",
+ "officialName": "USTC Youth of Excellence Scheme Full Scholarship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked only to an official source on the same host and in the same category; exact page evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 3 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/wuhan-university.json b/content/source-manifests/pilot/wuhan-university.json
index 33ba159..02075a8 100644
--- a/content/source-manifests/pilot/wuhan-university.json
+++ b/content/source-manifests/pilot/wuhan-university.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-wuhan-university",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["admission.whu.edu.cn","en.whu.edu.cn"],
"sources": [
{
"version": 1, "id": "whu-intl-admissions-home", "institutionId": "uni-wuhan-university", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -52,5 +54,28 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages require a later access-approved discovery crawl." },
{ "sourceCategory": "contacts", "status": "source_unavailable", "sourceIds": ["whu-admissions-contacts"], "note": "The official notices/contact anchor rejected automated access during review and is disabled pending policy verification." },
{ "sourceCategory": "catalog_anchor", "status": "source_unavailable", "sourceIds": ["whu-admissions-catalog-anchor"], "note": "The official catalog anchor rejected automated access during review and is disabled pending policy verification." }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "whu-application-portal",
+ "officialKey": "scholarship-hubei-provincial-international-student",
+ "officialName": "Hubei Provincial Scholarship for International Students",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "whu-application-portal",
+ "officialKey": "scholarship-wuhan-university-international-student",
+ "officialName": "Wuhan University Scholarship for International Students",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 2 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/content/source-manifests/pilot/zhejiang-university.json b/content/source-manifests/pilot/zhejiang-university.json
index 1249d80..d9ce5d6 100644
--- a/content/source-manifests/pilot/zhejiang-university.json
+++ b/content/source-manifests/pilot/zhejiang-university.json
@@ -1,8 +1,10 @@
{
- "version": 1,
+ "version": 2,
"institutionId": "uni-zhejiang-university",
"catalogStatus": "existing",
+ "manifestStatus": "in_progress",
"checkedAt": "2026-07-20",
+ "officialHosts": ["iczu.zju.edu.cn","intlstudent.zju.edu.cn"],
"sources": [
{
"version": 1, "id": "zju-intl-admissions-home", "institutionId": "uni-zhejiang-university", "entityType": "university", "sourceCategory": "international_admissions_home",
@@ -88,5 +90,44 @@
{ "sourceCategory": "program_detail", "status": "discovery_pending", "note": "Program-level pages will be discovered from the registered catalog anchors." },
{ "sourceCategory": "contacts", "status": "registered", "sourceIds": ["zju-admissions-contacts"] },
{ "sourceCategory": "catalog_anchor", "status": "registered", "sourceIds": ["zju-application-guide-anchor"] }
- ]
+ ],
+ "catalogReconciliation": {
+ "scope": "representative_international_programs",
+ "status": "in_progress",
+ "entries": [
+ {
+ "sourceId": "zju-intl-admissions-home",
+ "officialKey": "program-zhejiang-university-business-administration-master",
+ "officialName": "Business Administration",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "zju-intl-admissions-home",
+ "officialKey": "program-zhejiang-university-computer-science-bachelor",
+ "officialName": "Computer Science and Technology",
+ "entityType": "program",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "zju-intl-admissions-home",
+ "officialKey": "scholarship-zhejiang-provincial-government",
+ "officialName": "Zhejiang Provincial Government Scholarship",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ },
+ {
+ "sourceId": "zju-intl-admissions-home",
+ "officialKey": "scholarship-zhejiang-university-international-student",
+ "officialName": "Zhejiang University Scholarship for International Students",
+ "entityType": "scholarship",
+ "status": "pending",
+ "note": "Current public catalog identity is linked to this exact official URL; entity-level evidence and catalog completeness remain pending."
+ }
+ ],
+ "note": "V2 migration preserves the 2026-07-20 source audit. 4 exact-URL or same-host/same-category catalog relationships are queued for evidence review; all other identities and full-directory reconciliation remain unresolved."
+ }
}
diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md
index 85bf176..e323066 100644
--- a/docs/backup-and-restore.md
+++ b/docs/backup-and-restore.md
@@ -8,7 +8,13 @@
1. 使用 Cloudflare 只读/备份权限枚举普通数据表,并导出 `studyinchina-catalog` 和 `studyinchina-pipeline` 的数据。
2. 生成 `catalog.sql.gz`、`pipeline.sql.gz` 和 `backup-sha256.txt`。
-3. 上传到私有 R2 的 `backups/daily/YYYY-MM-DD/` 和 `backups/monthly/YYYY-MM/`。
+3. 上传到私有 R2 的 `backups/daily/YYYY-MM-DD/raw-v1/` 和 `backups/monthly/YYYY-MM/raw-v1/`。专用 Bucket 为 `studyinchina-backups`。
+4. The workflow reads the three daily objects back from R2 and revalidates gzip and SHA-256; only a successful readback creates an RPO checkpoint.
+
+`raw-v1` 是原始字节格式的版本标识。两个 `.sql.gz` 对象必须同时写入
+`Content-Type: application/gzip` 和 `Content-Encoding: identity`;否则 Wrangler
+可能在读回时透明解压,导致读回内容与上传文件的 SHA-256 不一致。旧的无
+`raw-v1` 对象不能作为有效恢复点,也不能被恢复演练自动选用。
R2 生命周期由 `npm run cloudflare:retention` 配置。备份 Token 应只具备 D1 导出和目标 R2 Bucket 写入所需的最小权限。
@@ -16,18 +22,18 @@ Catalog 使用 FTS5;Wrangler 不能把包含虚拟表的 D1 直接导出为完
## GitHub Actions configuration
-每日备份要求仓库 Actions secrets 中同时存在 `CLOUDFLARE_API_TOKEN` 与 `CLOUDFLARE_ACCOUNT_ID`。只应在 GitHub 的隐藏输入框或 `gh secret set` 的隐藏提示符中输入值;不得把值放入命令参数、工作流输出、Issue 或仓库文件。
+Daily backup requires repository secrets `CLOUDFLARE_D1_BACKUP_TOKEN` and `CLOUDFLARE_ACCOUNT_ID`. The token is restricted to D1 Read and write access to the private `studyinchina-backups` Bucket.
-工作流在安装 npm 依赖和访问 Cloudflare 之前运行无第三方依赖的配置检查。它只报告缺少的 secret 名称,不回显值。配置通过后,还会分别读取两个 D1 的远程元数据,以提前区分“凭据存在但权限、账号或数据库名称错误”与“导出过程失败”。
+Quarterly restore uses the protected `cloudflare-restore-drill` Environment. Its `CLOUDFLARE_D1_RESTORE_TOKEN` Environment secret has read-only access to that Bucket and is never shared with daily backup. Enable a required reviewer for this Environment.
-需要的最小外部配置和隐藏输入命令见 [`operations/data-maintenance.md`](./operations/data-maintenance.md#required-github-actions-secrets)。设置后手动重跑一次 `Cloudflare D1 backup`,只有完整导出、校验和六个 R2 对象上传全部成功,才能把该次运行记作新的恢复点。
+Enter secret values only through hidden GitHub or `gh secret set` prompts. The dependency-free preflight prints missing secret names but never values. A checkpoint is valid only after both databases export, local checksum verification, all six uploads, and daily-object readback succeed.
## Failure semantics and triage
- 红色且失败于配置检查:必要的 repository secret 缺失或账号 ID 格式无效;没有创建备份。
- 红色且失败于远程 D1 检查:token 无权访问目标账号/数据库、名称错误或 Cloudflare 不可用;没有开始导出。
- 红色且失败于导出或 artifact 校验:不得使用部分文件,且不会开始 R2 上传。
-- 红色且失败于 R2 上传:即使已经写入部分对象,也不能把该日期视为完整恢复点;应修复后整项重跑。
+- 红色且失败于 R2 上传或读回:即使已经写入部分对象,也不能把该日期视为完整恢复点;应修复后整项重跑。若读回文件不是 gzip 原始字节,确认对象位于 `raw-v1/` 且使用 `Content-Encoding: identity`。
- 任务显示 `runner_id=0` 且没有步骤:GitHub-hosted runner 从未分配,属于执行平台取消/排队问题,不是 D1 失败;应直接重跑并继续按 24 小时 RPO 计时。
任何失败或取消都不满足 `RPO <= 24 小时`。工作流会在可执行失败时写入 Job Summary,但在 runner 未分配的情况下没有代码能够运行,因此必须依靠 Actions 告警和人工重跑。
@@ -65,12 +71,14 @@ npm run cloudflare:restore-drill -- -BackupDirectory C:\secure\studyinchina-back
## 季度自动演练
-`.github/workflows/cloudflare-restore-drill.yml` 在每年 1、4、7、10 月执行,也支持手动选择 `YYYY-MM` 备份。工作流只从私有 R2 下载备份,然后运行本地恢复;恢复步骤不会收到 Cloudflare 凭据,也没有远程 D1 写入命令。
+`.github/workflows/cloudflare-restore-drill.yml` 在每年 1、4、7、10 月执行,也支持手动选择 `YYYY-MM` 备份。工作流只从私有 R2 下载备份,然后运行本地恢复;恢复步骤不会收到 Cloudflare 凭据,也没有远程 D1 写入命令。 The job is protected by the `cloudflare-restore-drill` Environment, and only the download step receives its read-only token.
演练通过后上传 JSON 报告,保留 90 天。失败时应按以下顺序处理:
1. 检查对象是否来自同一月份以及 checksum 文件是否匹配。
2. 判断是导出不完整、压缩文件损坏、数据库约束失败,还是 Schema/Release 语义失败。
+ 若对象不在 `backups/monthly/YYYY-MM/raw-v1/`,先将其视为不受支持的旧格式,
+ 不要通过跳过 gzip 或 SHA 校验来恢复。
3. 在 24 小时内选择前一日或前一月备份重试,并记录可恢复时间点。
4. 如果两个连续备份均不可恢复,立即暂停低优先级采集与 Release 切换,优先修复备份链路。
diff --git a/docs/operations/backup-restore-drill-2026-08-10.md b/docs/operations/backup-restore-drill-2026-08-10.md
new file mode 100644
index 0000000..c9476ae
--- /dev/null
+++ b/docs/operations/backup-restore-drill-2026-08-10.md
@@ -0,0 +1,47 @@
+# D1 backup and isolated restore evidence — 2026-08-10
+
+## Scope
+
+This record documents the first end-to-end backup proof for both StudyInChina D1 databases. It is operational evidence, not a claim that scheduled backup SLOs are already met.
+
+- Source databases: `studyinchina-catalog` and `studyinchina-pipeline`
+- Private R2 bucket: `studyinchina-backups`
+- Format: `raw-v1`
+- Daily prefix: `backups/daily/2026-08-10/raw-v1/`
+- Monthly prefix: `backups/monthly/2026-08/raw-v1/`
+- Lifecycle: daily checkpoints expire after 35 days; monthly checkpoints expire after 370 days
+
+The bucket has no public development URL or custom domain. The restore ran locally without Cloudflare credentials and could not write to either remote D1 database.
+
+## Export and readback
+
+| Database | SQL bytes | gzip bytes | SHA-256 |
+|---|---:|---:|---|
+| Catalog | 18,828,924 | 2,028,382 | `2bda279fc280697da2403e99f53ac22f52cb7db7601929e16febf92bdeb3d90f` |
+| Pipeline | 21,529,431 | 2,873,901 | `0fa12ac2f94b1c86754b25efe593c0836a6ac054f234e179833bbd51a90ec4bc` |
+
+All six daily and monthly objects were uploaded with `Content-Encoding: identity`, downloaded again, checked for the gzip magic header and verified against the stored checksum manifest. Both daily and monthly readbacks matched byte-for-byte.
+
+This explicit encoding is required because an R2 object whose `.gz` content is labelled as encoded gzip can otherwise be transparently decompressed during download, which changes the bytes and invalidates disaster-recovery hashes.
+
+## Isolated restore result
+
+Status: **passed**
+Mode: **local-isolated**
+Elapsed time: **101.198 seconds**
+
+| Database | Total restore | Bulk data import | Foreign-key violations | Integrity | Additional checks |
+|---|---:|---:|---:|---|---|
+| Catalog | 51.766 s | 1.259 s | 0 | `ok` | 14 triggers; FTS 3,931 / 3,931 |
+| Pipeline | 49.376 s | 1.879 s | 0 | `ok` | 80 triggers; sources 100; jobs 231; snapshots 71; candidates 65 |
+
+The Catalog verifier now compares restored entity counts with the active Release `counts_json`. It does not assume that every entity class must be non-zero; this matters because the current D1 Release legitimately reports zero program cycles. Institutions, programs and scholarships retain a non-zero safety gate.
+
+The restored Catalog currently reflects the existing D1 Release rather than the newer Git JSON catalogue. Its active projection contains 6 institutions, 1,006 programs, 0 program cycles and 55 scholarships. This is useful recovery evidence, but it also confirms that Production must remain on the JSON backend until three Shadow Releases complete with zero critical differences.
+
+## SLO interpretation
+
+- The measured restore time is below the internal current-scale target of 60 minutes and the external RTO objective of 4 hours.
+- One verified checkpoint does not satisfy the required 7/7 daily-backup streak or the 24-hour RPO over time.
+- Scheduled GitHub backup and restore workflows remain fail-closed until the dedicated backup token, protected restore environment/token and Vercel alias token are configured through hidden repository settings.
+- No Production Catalog pointer or public alias was changed during this exercise.
diff --git a/docs/operations/data-maintenance.md b/docs/operations/data-maintenance.md
index 28f2d6f..b8feeca 100644
--- a/docs/operations/data-maintenance.md
+++ b/docs/operations/data-maintenance.md
@@ -31,15 +31,22 @@ URLs exceeds the scheduled weekly capacity.
## Required GitHub Actions secrets
-The Vercel Git integration deploys `main` without an Action secret. Cloudflare
-backup, restore and remote Pipeline import require these repository secrets:
+The Vercel Git integration deploys `main` without an Action secret. Configure
+the daily backup and stable-alias repository secrets through hidden prompts:
```powershell
-gh secret set CLOUDFLARE_API_TOKEN --repo computersciencefreshmen/StudyInChina
+gh secret set CLOUDFLARE_D1_BACKUP_TOKEN --repo computersciencefreshmen/StudyInChina
gh secret set CLOUDFLARE_ACCOUNT_ID --repo computersciencefreshmen/StudyInChina
gh secret set VERCEL_TOKEN --repo computersciencefreshmen/StudyInChina
```
+Create the private backup-only R2 Bucket `studyinchina-backups`. Create a protected
+GitHub Environment named `cloudflare-restore-drill`, enable a required reviewer,
+and add `CLOUDFLARE_D1_RESTORE_TOKEN` as an Environment secret. The backup token
+needs only D1 Read plus write access to that Bucket; the restore token needs only
+read access to that Bucket. Remote deployment/import credentials remain separate
+from both backup credentials.
+
Enter each value only at the hidden prompt. Never place a token in a command
argument, committed file, issue, log or workflow output.
@@ -54,15 +61,26 @@ are therefore two separate signals.
When a successful Production deployment does not match the current `main` SHA,
the alias workflow remains a deliberate no-op and records a notice. When it does
-match `main`, the workflow first validates the immutable Vercel deployment URL
-and its `/api/v1/releases/current` response. Only a healthy candidate may receive
-the stable alias; the same endpoint is checked again through
-`studyinchina.vercel.app` after promotion. A missing token, invalid URL, failed
-candidate smoke test, failed alias command or failed stable-alias smoke test is a
-red workflow and requires operator action.
-
-The Cloudflare token should be limited to the StudyInChina account and only the
-D1/R2/Workers capabilities required by the workflows. `MINIMAX_API_KEY` remains
+match `main`, the workflow waits for the `ci.yml` main push run for that exact
+SHA to complete successfully. A failed, cancelled or timed-out CI run blocks
+promotion; Vercel Ready arriving before CI therefore cannot win the race. Only
+then does the workflow validate the immutable Vercel deployment URL
+and its `/api/v1/releases/current` response. Immediately before mutation, the
+workflow records the current immutable target of `studyinchina.vercel.app` and
+re-reads the GitHub `main` ref in the same shell step. Only a healthy candidate
+that is still the exact current SHA may receive the stable alias. It re-reads
+`main` again immediately after the Vercel command; if `main` advanced during the
+mutation, it restores the recorded target and fails. Concurrent promotion runs
+are serialized and an active mutation is never cancelled by a later deployment
+status event. The release endpoint is then checked again through the stable
+alias, including the exact deployment SHA and a positive numeric public program
+count. A missing token, invalid URL, failed candidate smoke, failed or rolled-back
+alias command, or failed stable smoke is a red workflow and requires operator
+action.
+
+Each Cloudflare token is limited to the StudyInChina account and one operational
+role. The generic `CLOUDFLARE_API_TOKEN` is intentionally not consumed by backup
+or restore workflows. `MINIMAX_API_KEY` remains
a Cloudflare Worker secret; it is not needed by the deterministic GitHub
refresh job.
@@ -76,13 +94,51 @@ refresh job.
- Smoke-test `/api/v1/releases/current`, `/api/v1/programs` and
`/api/v1/scholarships`.
+## Machine-readable P0 reliability audit
+
+`scripts/operations/evaluate-p0-reliability.mjs` evaluates an explicit local
+observation document and never fetches production state itself. A collector or
+operator must supply real timestamps and counts from these named sources:
+
+- the successful D1 backup readback verification artifact;
+- the active Catalog Release activation record;
+- the hourly ingestion scheduler heartbeat;
+- current Cloudflare Queue DLQ metrics;
+- the Pipeline D1 pending outbox query.
+
+The observation document uses format
+`studyinchina.p0-reliability-observations`, version `1`. Every section includes a
+bounded `source` identifier. Empty DLQ and outbox observations explicitly use a
+zero count and a `null` oldest timestamp; missing values are never inferred as
+zero.
+
+Run the evaluator without credentials or network access:
+
+```powershell
+node scripts/operations/evaluate-p0-reliability.mjs `
+ --input C:\secure\studyinchina-p0-observations.json `
+ --output C:\secure\studyinchina-p0-audit.json
+```
+
+Exit code `0` means every observation passed. Exit code `1` means a value was
+missing, inconsistent or outside its threshold: verified backup age at most 26
+hours, active Release age at most 48 hours, scheduler heartbeat age at most 90
+minutes, DLQ backlog exactly zero, and oldest pending outbox event younger than
+168 hours. The observation document itself must be no older than 15 minutes.
+The evaluator output is evidence, not telemetry collection; never populate it
+with guessed values or reuse a previous zero-backlog observation.
+
## Monthly checks
- Verify every public dynamic record has a future `reviewAfter`.
- Review all deadlines inside the 45-day window and all recurring-rule cycles.
- Re-run source discovery for new program and scholarship notices.
-- Confirm the daily D1 backup has both `catalog.sql.gz`, `pipeline.sql.gz` and
- a SHA-256 manifest; retain monthly copies for 12 months.
+- Confirm the daily D1 backup has `catalog.sql.gz`, `pipeline.sql.gz` and the
+ SHA-256 manifest together under `backups/daily/YYYY-MM-DD/raw-v1/` in
+ `studyinchina-backups`; verify that the readback artifact proves raw gzip
+ bytes and matching hashes before counting the checkpoint toward RPO. Restore
+ drills must consume `backups/monthly/YYYY-MM/raw-v1/`; retain monthly copies
+ for 12 months.
- Review infrastructure usage against the ¥60/¥80/¥95 cost thresholds.
## Adding a school
diff --git a/docs/operations/freshness-reverification-2026-08-10-wave-1.md b/docs/operations/freshness-reverification-2026-08-10-wave-1.md
new file mode 100644
index 0000000..3079974
--- /dev/null
+++ b/docs/operations/freshness-reverification-2026-08-10-wave-1.md
@@ -0,0 +1,25 @@
+# Freshness reverification — 2026-08-10, wave 1
+
+## Purpose
+
+This is a deliberately small, high-confidence review of applicant-facing deadlines, fees, requirements and scholarship coverage. Every accepted value was checked against a live official HTTPS source on 2026-08-10. Search results and aggregators were not used as evidence, and a historical value was not copied into a current cycle.
+
+The review cadence follows `docs/content-maintenance.md`: three days when a live deadline is within 45 days, seven days for other current admission and scholarship sources, and 30 days for stable program requirements.
+
+## Accepted facts
+
+| Record group | Official evidence | Accepted fields | Next review |
+|---|---|---|---|
+| Schwarzman Scholars, class of 2027–2028 | `https://www.schwarzmanscholars.org/admissions/application-instructions/`; `https://www.schwarzmanscholars.org/program-experience/`; Tsinghua's English-program list | Global route open 2026-04-08 through 2026-09-09; one-year full scholarship; exact English thresholds; one-minute video is recommended, not required | 2026-08-13 |
+| Soochow long-term Chinese | `https://oversea.suda.edu.cn/11295/list.htm` | 2026-09-30 deadline; CNY 8,500 per semester or CNY 17,000 per academic year; CNY 500 application fee | 2026-08-17 |
+| Soochow ICLT Scholarship | `https://oversea.suda.edu.cn/oversea_en/bb/fd/c11345a441341/page.htm`; CLEC 2026 official guideline PDF | March 2027 one-semester route closes 2026-10-31; five-month duration; HSK 3 score 180 plus HSKK; tuition, accommodation, living allowance and medical-insurance coverage | 2026-08-17 |
+| SCAU Guangdong Government Scholarship | `https://gdic.scau.edu.cn/2026/0421/c11155a432798/page.htm` | 2026-09-01 deadline; one-time awards of CNY 10,000 / 20,000 / 30,000 for bachelor's / master's / doctoral students | 2026-08-13 |
+| ZUST ICLT Scholarship | `https://ies.zust.edu.cn/info/1271/4219.htm`; CLEC 2026 official guideline PDF | March 2027 semester deadline 2026-10-31; master HSK 5 score 210 and HSKK 60; semester HSK 3 score 180 plus HSKK; tuition, accommodation, living allowance and medical-insurance coverage | 2026-08-17 for cycles/scholarship; 2026-09-09 for program requirements |
+
+The CLEC guideline is the already registered source `src-gov-clec` at `https://pmplatform.chinese.cn/tmp/2026/2/6/94005b2e-f2e9-438e-85e7-12212f0e9968.pdf`. Its allowance amounts vary by scholarship category, so the single `stipendCnyPerMonth` field remains `null` instead of collapsing multiple tiers into one misleading value.
+
+## Rejected / unresolved fact
+
+NUIST's current official page says the one-semester route can start in March 2027, but its deadline list labels October 29 as applying to “March 2026”. The CLEC 2026 guideline instead states March 2027 with a general October 31 deadline. The existing NUIST October 29 value was not extended or rewritten in this wave. Its `reviewAfter` remains 2026-08-10 and it requires a second institution-specific official source or a corrected NUIST notice before another verified horizon is assigned.
+
+This is a high-risk date association issue, not evidence that the scholarship or program identity is invalid.
diff --git a/docs/operations/freshness-reverification-2026-08-10-wave-2.md b/docs/operations/freshness-reverification-2026-08-10-wave-2.md
new file mode 100644
index 0000000..2695413
--- /dev/null
+++ b/docs/operations/freshness-reverification-2026-08-10-wave-2.md
@@ -0,0 +1,27 @@
+# Freshness reverification — 2026-08-10, wave 2
+
+## Scope and decision rule
+
+This wave intentionally stops at ten existing high-value records. It does not discover new entities or broaden program eligibility. Each accepted value was re-read on a live, already-registered official HTTPS page on 2026-08-10. A record was returned to `verified` only when the institution page still stated the same intake, deadline and any published fee or coverage value without ambiguity.
+
+## Accepted records and evidence locators
+
+| Records | Official source | Exact evidence locator | Published disposition | Next review |
+|---|---|---|---|---|
+| Five Wenzhou-Kean spring-transfer cycles: Finance, Global Business, Computer Science, Biology, Architecture | `https://admission.wku.edu.cn/en/internationalstudents` | `Undergraduate International Admissions` → `Prepare Your Application` → `Application Deadlines`; `Tuition & Fees (2026-2027 Academic Year)` → `Annual Fees` and `One-time Fees` | Spring 2027 remains transfer-only; deadline 2026-11-01; tuition CNY 68,000 per academic year; application fee CNY 400 | 2026-08-17 |
+| Shanghai Normal one-semester ICLT, spring 2027 | `https://iao.shnu.edu.cn/ec/35/c20435a846901/page.htm` | `二、奖学金类别及申请条件` → `3.一学期研修生`; `四、申请截止日期` → item 2 | Five-month spring 2027 route; deadline 2026-10-31; cycle remains `dates-only` | 2026-08-17 |
+| PKU International Chinese Language Teachers Scholarship | `https://www.isd.pku.edu.cn/cn/scholarship/detail.php?id=6`; supporting degree notice `https://www.isd.pku.edu.cn/cn/scholarship/detail.php?id=666` | General visiting/pre-university notice → `资助内容` and `申请时间` items 1–2; degree notice → `资助内容` and `申请时间` | February 2027 non-degree route deadline remains 2026-10-31; tuition, double-room accommodation, living allowance and medical insurance remain confirmed | 2026-08-17 |
+| Sichuan International Studies University ICLT Scholarship | `https://studyinsisu.sisu.edu.cn/jxj/gjzwjsjxj/5e77e5b50f64418eb76adedd432736f9.htm` | `三、奖学金资助内容及标准`; `四、奖学金申请流程` → item 5 | Funding components remain confirmed; latest published future deadline remains 2026-10-31 | 2026-08-17 |
+| Shenyang Normal University ICLT Scholarship | `https://cie.synu.edu.cn/2026/0415/c5760a112915/page.htm` | `五、申请截止日期` → item 3; `七、资助内容及标准` | December four-week route remains open until 2026-09-15; tuition, accommodation, living allowance and insurance remain confirmed | 2026-08-13 |
+| Hainan Normal University ICLT Scholarship | `https://webplus.hainnu.edu.cn/_s74/2025/1110/c1345a160838/page.psp` | `7. Scholarship Programs` → item 2 | March entry deadline remains 2026-10-31; coverage stays `unknown` because this accessible institution text does not state benefit amounts/components | 2026-08-17 |
+
+The five Wenzhou-Kean entries share one institution-level admissions fact set. They remain separate records only because their existing program identities and slugs are distinct; this wave does not add programs or infer program-specific exceptions.
+
+## Withheld and conflicting candidates
+
+- Shenzhen University's registered ICLT page timed out during the live check. Its record was not refreshed from cached or search-result text.
+- Central China Normal University's registered scholarship page also timed out and was not refreshed.
+- Sichuan Normal University's Chinese text says the future intake is March 2027, while the English rendering on the same page says March 2025. No record from that page was promoted in this wave; the mismatch requires a corrected notice or a second institution-specific source.
+- NUIST remains excluded for the date-association conflict documented in wave 1.
+
+These exclusions are intentional: source availability or internal conflict is not converted into a guessed current fact.
diff --git a/docs/operations/pilot-source-manifest-v2-audit.md b/docs/operations/pilot-source-manifest-v2-audit.md
new file mode 100644
index 0000000..c5a6250
--- /dev/null
+++ b/docs/operations/pilot-source-manifest-v2-audit.md
@@ -0,0 +1,77 @@
+# Pilot Source Manifest V2 migration audit
+
+Audit date: 2026-08-10
+Formal source check date retained from V1: 2026-07-20
+Disposition: `in_progress` / fail-closed
+
+## Outcome
+
+The ten pilot institution manifests now use the institution-level
+`SourceManifestV2` contract. This is a trust-ledger migration, not a claim that
+the official catalogue has been fully reconciled.
+
+| Institution | Preserved official sources | Pending reconciliation seeds |
+|---|---:|---:|
+| Fudan University | 10 | 2 |
+| Harbin Institute of Technology | 9 | 5 |
+| Nanjing University | 9 | 3 |
+| Peking University | 12 | 9 |
+| Shanghai Jiao Tong University | 9 | 1 |
+| Sun Yat-sen University | 10 | 3 |
+| Tsinghua University | 14 | 2 |
+| University of Science and Technology of China | 11 | 3 |
+| Wuhan University | 5 | 2 |
+| Zhejiang University | 11 | 4 |
+| **Total** | **100** | **34** |
+
+The public trust ledger therefore reports:
+
+- 10 formal V2 manifests in progress;
+- 0 legacy V1 upgrade paths remaining in the pilot;
+- 0 complete formal catalogue reconciliations;
+- 0 publication-eligible pilot manifests.
+
+## Locked migration invariants
+
+The migration preserves each manifest's existing:
+
+- institution ID and catalogue status;
+- source IDs, official URLs, allowlists, schedules and extraction contracts;
+- `enabled` and `robots` safety semantics;
+- sixteen-category coverage ledger;
+- 2026-07-20 source check date.
+
+The nested fetch manifests intentionally remain V1 because that is the format
+consumed by the ingestion Worker. V2 is the institution-level contract around
+those sources.
+
+`officialHosts` is the normalized union of the existing source and redirect
+allowlists. No new fetch host was introduced.
+
+## Reconciliation seeding policy
+
+The 2026-08-10 disabled candidate cohort was used only as an audit index. A
+catalogue identity was seeded into a formal manifest only when its candidate
+source could be mapped to an existing pilot source by either:
+
+1. the exact normalized official URL; or
+2. the same official host and the same source category.
+
+Every seeded item remains `pending`. Same-host mappings explicitly state that
+exact page evidence is still missing. Candidate-only identities with neither
+mapping were omitted instead of being guessed.
+
+## Remaining gates
+
+Before any pilot manifest can become `complete`, a reviewer must still:
+
+- resolve all 34 pending identities with entity-level evidence;
+- reconcile every official catalogue item to a terminal disposition;
+- resolve 61 `discovery_pending` coverage categories;
+- re-check the four `source_unavailable` categories where access permits;
+- remove all audit-only markers and pending entries;
+- pass the existing checksum-bound promotion review and full manifest
+ validation.
+
+Until then, `isCatalogReconciliationComplete()` remains false and the
+promotion gate stays closed.
diff --git a/docs/source-manifest-cohort-candidates.md b/docs/source-manifest-cohort-candidates.md
index e4e53b8..6a258e0 100644
--- a/docs/source-manifest-cohort-candidates.md
+++ b/docs/source-manifest-cohort-candidates.md
@@ -78,3 +78,9 @@ short-lived review artifact.
Promotion remains a separate evidence-review action. Candidate artifacts must
never be copied wholesale into the formal manifest directory.
+
+A `representative_international_programs` scope is discovery evidence, never a
+complete catalogue reconciliation, even if an upstream artifact incorrectly
+labels it `complete`. The completion and publication gates accept only
+`full_official_catalog`, or an explicitly final `limited_official_catalog`,
+with terminal reconciliation entries and no pending outcomes.
diff --git a/package.json b/package.json
index 4e1d501..0d65424 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"pipeline:build-source-manifest-candidates": "tsx scripts/ingestion/build-source-manifest-cohort.ts",
"pipeline:verify-source-manifest-candidates": "tsx scripts/ingestion/build-source-manifest-cohort.ts --verify-artifact",
"pipeline:promote-source-manifest-candidate": "tsx scripts/ingestion/promote-source-manifest-candidate.ts",
+ "pipeline:manifest-trust-ledger": "tsx scripts/ingestion/build-source-manifest-trust-ledger.ts",
"pipeline:build-sources": "tsx scripts/ingestion/build-source-import.ts",
"benchmark:catalog": "tsx scripts/catalog/benchmark-catalog.ts",
"benchmark:catalog:smoke": "tsx scripts/catalog/benchmark-catalog.ts --institutions 25 --programs 1000 --cycles 3000 --iterations 50 --warmup 10 --output .benchmark/catalog-performance-smoke.json",
@@ -54,6 +55,7 @@
"check:program-coverage:strict": "tsx scripts/quality/check-program-coverage.ts --mode strict --minimum 1",
"quality:inventory-untracked": "tsx scripts/quality/inventory-untracked-assets.ts",
"quality:synthetic-regression": "tsx scripts/quality/run-synthetic-regression.ts",
+ "quality:reliability": "node scripts/operations/evaluate-p0-reliability.mjs",
"quality:platform-scorecard": "tsx scripts/quality/platform-data-quality.ts"
},
"dependencies": {
diff --git a/scripts/cloudflare/backup-preflight.ts b/scripts/cloudflare/backup-preflight.ts
index 166fcac..981b8e9 100644
--- a/scripts/cloudflare/backup-preflight.ts
+++ b/scripts/cloudflare/backup-preflight.ts
@@ -11,7 +11,9 @@ export const BACKUP_DATABASES = [
'studyinchina-catalog',
'studyinchina-pipeline',
] as const
-export const BACKUP_BUCKET = 'studyinchina-releases'
+export const BACKUP_BUCKET = 'studyinchina-backups'
+export const BACKUP_TOKEN_SECRET = 'CLOUDFLARE_D1_BACKUP_TOKEN'
+export const RESTORE_TOKEN_SECRET = 'CLOUDFLARE_D1_RESTORE_TOKEN'
export const BACKUP_CONFIGURATION_DOC =
'docs/backup-and-restore.md#github-actions-configuration'
const BACKUP_FILES = ['catalog.sql.gz', 'pipeline.sql.gz'] as const
@@ -25,11 +27,11 @@ export type BackupArtifactReport = {
export function validateBackupCredentials(
environment: Readonly>,
): { databases: number; bucket: string } {
- const token = environment.CLOUDFLARE_API_TOKEN?.trim()
+ const token = environment[BACKUP_TOKEN_SECRET]?.trim()
const accountId = environment.CLOUDFLARE_ACCOUNT_ID?.trim()
if (!token || !accountId) {
const missing = [
- !token ? 'CLOUDFLARE_API_TOKEN' : undefined,
+ !token ? BACKUP_TOKEN_SECRET : undefined,
!accountId ? 'CLOUDFLARE_ACCOUNT_ID' : undefined,
].filter((name): name is string => Boolean(name))
throw new Error(
@@ -46,6 +48,30 @@ export function validateBackupCredentials(
return { databases: BACKUP_DATABASES.length, bucket: BACKUP_BUCKET }
}
+export function validateRestoreCredentials(
+ environment: Readonly>,
+): { bucket: string } {
+ const token = environment[RESTORE_TOKEN_SECRET]?.trim()
+ const accountId = environment.CLOUDFLARE_ACCOUNT_ID?.trim()
+ if (!token || !accountId) {
+ const missing = [
+ !token ? RESTORE_TOKEN_SECRET : undefined,
+ !accountId ? 'CLOUDFLARE_ACCOUNT_ID' : undefined,
+ ].filter((name): name is string => Boolean(name))
+ throw new Error(
+ `Missing required protected-environment secret(s): ${missing.join(', ')}. `
+ + `Configure them before rerunning; see ${BACKUP_CONFIGURATION_DOC}. No restore artifact was downloaded.`,
+ )
+ }
+ if (!/^[0-9a-f]{32}$/iu.test(accountId)) {
+ throw new Error(
+ 'CLOUDFLARE_ACCOUNT_ID must be a 32-character hexadecimal identifier. '
+ + `See ${BACKUP_CONFIGURATION_DOC}. No restore artifact was downloaded.`,
+ )
+ }
+ return { bucket: BACKUP_BUCKET }
+}
+
function escapeWorkflowCommand(value: string): string {
return value
.replaceAll('%', '%25')
@@ -134,6 +160,11 @@ function main(): void {
process.stdout.write(`${JSON.stringify({ ok: true, phase, ...result })}\n`)
return
}
+ if (phase === 'restore-credentials') {
+ const result = validateRestoreCredentials(process.env)
+ process.stdout.write(`${JSON.stringify({ ok: true, phase, ...result })}\n`)
+ return
+ }
if (phase === 'artifacts') {
const directory = argument(args, '--directory')
if (!directory) throw new Error('--directory is required for artifact preflight')
@@ -141,7 +172,7 @@ function main(): void {
process.stdout.write(`${JSON.stringify({ ok: true, phase, artifacts })}\n`)
return
}
- throw new Error('Use --phase credentials or --phase artifacts')
+ throw new Error('Use --phase credentials, --phase restore-credentials or --phase artifacts')
}
if (isMainModule()) {
diff --git a/scripts/cloudflare/configure-retention.ps1 b/scripts/cloudflare/configure-retention.ps1
index 15be13e..f09d4bd 100644
--- a/scripts/cloudflare/configure-retention.ps1
+++ b/scripts/cloudflare/configure-retention.ps1
@@ -1,5 +1,5 @@
param(
- [string]$Bucket = 'studyinchina-releases'
+ [string]$Bucket = 'studyinchina-backups'
)
$ErrorActionPreference = 'Stop'
diff --git a/scripts/cloudflare/import-restored-d1.mjs b/scripts/cloudflare/import-restored-d1.mjs
new file mode 100644
index 0000000..ec4bcdc
--- /dev/null
+++ b/scripts/cloudflare/import-restored-d1.mjs
@@ -0,0 +1,134 @@
+import { constants as bufferConstants } from 'node:buffer'
+import { readFileSync, readdirSync, statSync } from 'node:fs'
+import { basename, join, resolve } from 'node:path'
+import { DatabaseSync } from 'node:sqlite'
+import { performance } from 'node:perf_hooks'
+
+const [stateDirectory, kind, sqlPath] = process.argv.slice(2)
+
+if (!stateDirectory || !['catalog', 'pipeline'].includes(kind) || !sqlPath) {
+ throw new Error(
+ 'Usage: node import-restored-d1.mjs ',
+ )
+}
+
+const markerTable = kind === 'catalog' ? 'catalog_releases' : 'records'
+
+function sqliteFiles(directory) {
+ const files = []
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
+ const path = join(directory, entry.name)
+ if (entry.isDirectory()) files.push(...sqliteFiles(path))
+ if (entry.isFile() && entry.name.endsWith('.sqlite') && entry.name !== 'metadata.sqlite') {
+ files.push(path)
+ }
+ }
+ return files
+}
+
+function findIsolatedDatabase(directory) {
+ const matches = []
+ for (const path of sqliteFiles(resolve(directory))) {
+ const database = new DatabaseSync(path)
+ try {
+ const marker = database
+ .prepare("SELECT COUNT(*) AS count FROM sqlite_schema WHERE type = 'table' AND name = ?")
+ .get(markerTable)
+ if (marker.count === 1) matches.push({ path, database })
+ else database.close()
+ } catch (error) {
+ database.close()
+ throw error
+ }
+ }
+
+ if (matches.length !== 1) {
+ for (const match of matches) match.database.close()
+ throw new Error(`Expected one isolated ${kind} SQLite file, found ${matches.length}`)
+ }
+ return matches[0]
+}
+
+function hasExplicitTransaction(sql) {
+ return /^\s*(?:BEGIN(?:\s+(?:DEFERRED|IMMEDIATE|EXCLUSIVE))?(?:\s+TRANSACTION)?|COMMIT(?:\s+TRANSACTION)?|END(?:\s+TRANSACTION)?|ROLLBACK(?:\s+TRANSACTION)?|SAVEPOINT\s+|RELEASE\s+)\b/imu.test(
+ sql,
+ )
+}
+
+function rollbackIfNeeded(database) {
+ if (database.isTransaction) database.exec('ROLLBACK')
+}
+
+function importData(database, sql) {
+ database.exec('PRAGMA foreign_keys = ON;')
+ const foreignKeys = database.prepare('PRAGMA foreign_keys').get()
+ if (Number(foreignKeys.foreign_keys) !== 1) {
+ throw new Error('Unable to enable foreign-key enforcement for the isolated restore')
+ }
+
+ if (hasExplicitTransaction(sql)) {
+ try {
+ database.exec(sql)
+ if (database.isTransaction) {
+ database.exec('ROLLBACK')
+ throw new Error('Backup SQL left an explicit transaction open')
+ }
+ return 'archive'
+ } catch (error) {
+ rollbackIfNeeded(database)
+ throw error
+ }
+ }
+
+ database.exec('BEGIN IMMEDIATE; PRAGMA defer_foreign_keys = TRUE;')
+ try {
+ database.exec(sql)
+ database.exec('COMMIT')
+ return 'wrapper'
+ } catch (error) {
+ rollbackIfNeeded(database)
+ throw error
+ }
+}
+
+function main() {
+ const resolvedSqlPath = resolve(sqlPath)
+ const sqlBytes = statSync(resolvedSqlPath).size
+ if (sqlBytes <= 0) throw new Error('Data-only SQL backup is empty')
+ if (sqlBytes > bufferConstants.MAX_STRING_LENGTH) {
+ throw new Error(
+ `Data-only SQL backup is too large for the local bulk importer (${sqlBytes} bytes)`,
+ )
+ }
+
+ const sql = readFileSync(resolvedSqlPath, 'utf8')
+ if (Buffer.byteLength(sql, 'utf8') !== sqlBytes) {
+ throw new Error('Data-only SQL backup is not valid UTF-8 text')
+ }
+
+ const { path, database } = findIsolatedDatabase(stateDirectory)
+ const startedAt = performance.now()
+ try {
+ const transactionMode = importData(database, sql)
+ process.stdout.write(
+ JSON.stringify({
+ databaseFile: basename(path),
+ engine: 'node:sqlite',
+ transactionMode,
+ sqlBytes,
+ elapsedMs: Math.round((performance.now() - startedAt) * 1000) / 1000,
+ }),
+ )
+ } finally {
+ rollbackIfNeeded(database)
+ database.close()
+ }
+}
+
+try {
+ main()
+} catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ process.stderr.write(`Local D1 bulk import failed: ${message}\n`)
+ process.exitCode = 1
+}
diff --git a/scripts/cloudflare/restore-drill.ps1 b/scripts/cloudflare/restore-drill.ps1
index 81bce59..76e0e5f 100644
--- a/scripts/cloudflare/restore-drill.ps1
+++ b/scripts/cloudflare/restore-drill.ps1
@@ -50,6 +50,11 @@ if (-not (Test-Path -LiteralPath $localVerifier -PathType Leaf)) {
throw 'Local D1 verifier is missing.'
}
+$localImporter = Join-Path (Join-Path (Join-Path $repositoryRoot 'scripts') 'cloudflare') 'import-restored-d1.mjs'
+if (-not (Test-Path -LiteralPath $localImporter -PathType Leaf)) {
+ throw 'Local D1 bulk importer is missing.'
+}
+
# Keep all Wrangler state inside the disposable drill directory. The script has
# no remote mode and every D1 invocation below includes --local explicitly.
$env:XDG_CONFIG_HOME = Join-Path $runDirectory 'xdg-config'
@@ -151,6 +156,23 @@ function Expand-GzipBounded {
}
}
+function Write-RestoreProgress {
+ param(
+ [string]$Kind,
+ [string]$Phase,
+ [string]$Status,
+ [long]$ElapsedMilliseconds = -1
+ )
+
+ $elapsed = if ($ElapsedMilliseconds -ge 0) {
+ " elapsedMs=$ElapsedMilliseconds"
+ } else {
+ ''
+ }
+ $timestamp = (Get-Date).ToUniversalTime().ToString('o')
+ [Console]::Error.WriteLine("[restore-drill] timestamp=$timestamp database=$Kind phase=$Phase status=$Status$elapsed")
+}
+
function Invoke-Wrangler {
param([string[]]$Arguments)
@@ -217,7 +239,11 @@ function Initialize-IsolatedSchema {
if ($migrationFiles.Count -eq 0) {
throw "No migrations found for $Kind."
}
+ $migrationIndex = 0
foreach ($migration in $migrationFiles) {
+ $migrationIndex += 1
+ $migrationWatch = [System.Diagnostics.Stopwatch]::StartNew()
+ Write-RestoreProgress $Kind "migration-$migrationIndex-$($migration.Name)" 'started'
Invoke-Wrangler @(
'd1', 'execute', $Binding,
'--local',
@@ -226,6 +252,8 @@ function Initialize-IsolatedSchema {
'--file', $migration.FullName,
'--yes'
) | Out-Null
+ $migrationWatch.Stop()
+ Write-RestoreProgress $Kind "migration-$migrationIndex-$($migration.Name)" 'completed' $migrationWatch.ElapsedMilliseconds
}
# A data-only export may contain rows that would correctly be rejected by
@@ -311,6 +339,35 @@ function Invoke-LocalDatabaseVerifier {
}
}
+function Invoke-LocalDataImporter {
+ param(
+ [string]$Kind,
+ [string]$SqlPath
+ )
+
+ $previousErrorPreference = $ErrorActionPreference
+ try {
+ $ErrorActionPreference = 'Continue'
+ $output = @(& $node --no-warnings $localImporter $stateDirectory $Kind $SqlPath 2>&1 | ForEach-Object { "$_" })
+ $exitCode = $LASTEXITCODE
+ } finally {
+ $ErrorActionPreference = $previousErrorPreference
+ }
+ $json = ($output -join "`n").Trim()
+ if ($exitCode -ne 0) {
+ throw "Local SQLite data import failed for $Kind`n$json"
+ }
+ try {
+ $result = $json | ConvertFrom-Json
+ } catch {
+ throw "Local SQLite data importer returned invalid JSON for $Kind`n$json"
+ }
+ if ([string]$result.engine -ne 'node:sqlite') {
+ throw "Local SQLite data importer returned an unexpected engine for $Kind"
+ }
+ return $result
+}
+
function Test-RestoredDatabase {
param([System.Collections.IDictionary]$Definition)
@@ -318,6 +375,11 @@ function Test-RestoredDatabase {
$binding = [string]$Definition.binding
$archiveName = "$kind.sql.gz"
$archivePath = Join-Path $backupRoot $archiveName
+ $databaseWatch = [System.Diagnostics.Stopwatch]::StartNew()
+ $phaseDurations = [ordered]@{}
+ Write-RestoreProgress $kind 'database' 'started'
+ $phaseWatch = [System.Diagnostics.Stopwatch]::StartNew()
+
if (-not (Test-Path -LiteralPath $archivePath -PathType Leaf)) {
throw "Backup archive is missing: $archiveName"
}
@@ -330,28 +392,56 @@ function Test-RestoredDatabase {
if ($actualSha256 -ne $expectedSha256) {
throw "SHA-256 mismatch for $archiveName"
}
+ $phaseWatch.Stop()
+ $phaseDurations['archiveVerification'] = $phaseWatch.ElapsedMilliseconds
+ Write-RestoreProgress $kind 'archive-verification' 'completed' $phaseWatch.ElapsedMilliseconds
+ $phaseWatch.Restart()
+ Write-RestoreProgress $kind 'decompression' 'started'
$sqlPath = Join-Path $runDirectory "$kind.sql"
$uncompressedBytes = Expand-GzipBounded $archivePath $sqlPath $MaxUncompressedBytes
if ($uncompressedBytes -le 0) {
throw "Backup archive is empty after decompression: $archiveName"
}
+ $phaseWatch.Stop()
+ $phaseDurations['decompression'] = $phaseWatch.ElapsedMilliseconds
+ Write-RestoreProgress $kind 'decompression' 'completed' $phaseWatch.ElapsedMilliseconds
+ $phaseWatch.Restart()
+ Write-RestoreProgress $kind 'schema-initialization' 'started'
$triggerRows = @(Initialize-IsolatedSchema $binding $kind)
- Invoke-Wrangler @(
- 'd1', 'execute', $binding,
- '--local',
- '--persist-to', $stateDirectory,
- '--config', $configPath,
- '--file', $sqlPath,
- '--yes'
- ) | Out-Null
+ $phaseWatch.Stop()
+ $phaseDurations['schemaInitialization'] = $phaseWatch.ElapsedMilliseconds
+ Write-RestoreProgress $kind 'schema-initialization' 'completed' $phaseWatch.ElapsedMilliseconds
+
+ $phaseWatch.Restart()
+ Write-RestoreProgress $kind 'data-import' 'started'
+ $dataImport = Invoke-LocalDataImporter $kind $sqlPath
+ if ([long]$dataImport.sqlBytes -ne [long]$uncompressedBytes) {
+ throw "$kind data importer byte count does not match decompressed backup"
+ }
+ $phaseWatch.Stop()
+ $phaseDurations['dataImport'] = $phaseWatch.ElapsedMilliseconds
+ Write-RestoreProgress $kind 'data-import' 'completed' $phaseWatch.ElapsedMilliseconds
+
+ $phaseWatch.Restart()
+ Write-RestoreProgress $kind 'trigger-search-restore' 'started'
Restore-IsolatedTriggersAndSearch $binding $kind $triggerRows
+ $phaseWatch.Stop()
+ $phaseDurations['triggerAndSearchRestore'] = $phaseWatch.ElapsedMilliseconds
+ Write-RestoreProgress $kind 'trigger-search-restore' 'completed' $phaseWatch.ElapsedMilliseconds
# D1 deliberately blocks integrity_check through its query API. The verifier
# opens only Wrangler's isolated local SQLite file in read-only mode so both
# integrity_check and foreign_key_check can still be executed exactly.
+ $phaseWatch.Restart()
+ Write-RestoreProgress $kind 'verification' 'started'
$verification = Invoke-LocalDatabaseVerifier $kind
+ $phaseWatch.Stop()
+ $phaseDurations['verification'] = $phaseWatch.ElapsedMilliseconds
+ Write-RestoreProgress $kind 'verification' 'completed' $phaseWatch.ElapsedMilliseconds
+ $databaseWatch.Stop()
+ $phaseDurations['total'] = $databaseWatch.ElapsedMilliseconds
$databaseReport = [ordered]@{
kind = $kind
isolatedDatabaseName = [string]$Definition.databaseName
@@ -363,6 +453,14 @@ function Test-RestoredDatabase {
integrityCheck = [string]$verification.integrityCheck
coreTables = @($verification.coreTables)
triggerCount = [long]$verification.triggerCount
+ dataImport = [ordered]@{
+ engine = [string]$dataImport.engine
+ transactionMode = [string]$dataImport.transactionMode
+ sqlBytes = [long]$dataImport.sqlBytes
+ elapsedMs = [double]$dataImport.elapsedMs
+ databaseFile = [string]$dataImport.databaseFile
+ }
+ phaseDurationsMs = $phaseDurations
}
if ($kind -eq 'catalog') {
@@ -389,6 +487,7 @@ function Test-RestoredDatabase {
}
}
+ Write-RestoreProgress $kind 'database' 'completed' $databaseWatch.ElapsedMilliseconds
return $databaseReport
}
diff --git a/scripts/cloudflare/verify-restored-d1.mjs b/scripts/cloudflare/verify-restored-d1.mjs
index 703170e..5e6be2f 100644
--- a/scripts/cloudflare/verify-restored-d1.mjs
+++ b/scripts/cloudflare/verify-restored-d1.mjs
@@ -108,6 +108,7 @@ try {
cr.release_status,
cr.data_date,
cr.generated_at,
+ cr.counts_json,
(SELECT COUNT(*) FROM institutions WHERE release_id = rp.current_release_id) AS institutions,
(SELECT COUNT(*) FROM programs WHERE release_id = rp.current_release_id) AS programs,
(SELECT COUNT(*) FROM program_cycles WHERE release_id = rp.current_release_id) AS program_cycles,
@@ -122,8 +123,35 @@ try {
}
const release = releases[0]
if (release.release_status !== 'active') throw new Error('Catalog current release is not active')
- for (const countName of ['institutions', 'programs', 'program_cycles', 'scholarships']) {
- if (release[countName] <= 0) throw new Error(`Catalog current release has no ${countName} rows`)
+
+ let declaredCounts
+ try {
+ declaredCounts = JSON.parse(release.counts_json)
+ } catch {
+ throw new Error('Catalog current release has invalid counts_json')
+ }
+ const countContracts = [
+ ['institutions', 'universities'],
+ ['programs', 'programs'],
+ ['program_cycles', 'admissionCycles'],
+ ['scholarships', 'scholarships'],
+ ]
+ for (const [actualName, declaredName] of countContracts) {
+ const actual = Number(release[actualName])
+ const declared = Number(declaredCounts[declaredName])
+ if (!Number.isSafeInteger(declared) || declared < 0) {
+ throw new Error(`Catalog current release has invalid declared ${declaredName} count`)
+ }
+ if (actual !== declared) {
+ throw new Error(
+ `Catalog current release ${actualName} count mismatch: expected ${declared}, restored ${actual}`,
+ )
+ }
+ }
+ for (const countName of ['institutions', 'programs', 'scholarships']) {
+ if (release[countName] <= 0) {
+ throw new Error(`Catalog current release has no ${countName} rows`)
+ }
}
const fts = database
diff --git a/scripts/ingestion/build-source-import.ts b/scripts/ingestion/build-source-import.ts
index 5182e9e..20db147 100644
--- a/scripts/ingestion/build-source-import.ts
+++ b/scripts/ingestion/build-source-import.ts
@@ -2,9 +2,11 @@ import { mkdirSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import {
+ isCatalogReconciliationComplete,
validateSourceManifestDirectory,
type SourceManifestRecord,
} from '../source-manifest-registry'
+import type { SourceManifestV1 } from '../../workers/ingestion/src/types'
type SqlValue = string | number | null
@@ -16,6 +18,28 @@ export type SourceImportArtifacts = {
generatedAt: string
}
+export type NormalizedSourceManifestImport = {
+ institutionId: string
+ manifestVersion: 1 | 2
+ checkedAt: string
+ catalogReconciliationComplete: boolean
+ sources: readonly SourceManifestV1[]
+}
+
+export function normalizeSourceManifestsForImport(
+ records: readonly SourceManifestRecord[],
+): NormalizedSourceManifestImport[] {
+ return records.map((record) => ({
+ institutionId: record.institutionId,
+ manifestVersion: record.version,
+ checkedAt: record.checkedAt,
+ // V1 never claimed reconciliation completeness. A V2 in_progress
+ // envelope is importable, but remains explicitly incomplete.
+ catalogReconciliationComplete: isCatalogReconciliationComplete(record),
+ sources: record.sources,
+ }))
+}
+
function sqlValue(value: SqlValue) {
if (value === null) return 'NULL'
if (typeof value === 'number') return String(value)
@@ -27,10 +51,11 @@ export function buildPilotSourceImport(
generatedAt = new Date().toISOString(),
): SourceImportArtifacts {
if (Number.isNaN(Date.parse(generatedAt))) throw new Error('generatedAt must be an ISO timestamp')
- const sources = records
+ const normalized = normalizeSourceManifestsForImport(records)
+ const sources = normalized
.flatMap((record) => record.sources)
.sort((left, right) => left.id.localeCompare(right.id))
- const institutionIds = [...new Set(records.map((record) => record.institutionId))].sort()
+ const institutionIds = [...new Set(normalized.map((record) => record.institutionId))].sort()
const sourceIds = new Set()
for (const source of sources) {
if (sourceIds.has(source.id)) throw new Error(`Duplicate source id: ${source.id}`)
diff --git a/scripts/ingestion/build-source-manifest-trust-ledger.ts b/scripts/ingestion/build-source-manifest-trust-ledger.ts
new file mode 100644
index 0000000..e931321
--- /dev/null
+++ b/scripts/ingestion/build-source-manifest-trust-ledger.ts
@@ -0,0 +1,532 @@
+import {
+ existsSync,
+ mkdirSync,
+ readFileSync,
+ writeFileSync,
+} from 'node:fs'
+import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'
+import { pathToFileURL } from 'node:url'
+import { selectPublishedData } from '../../src/lib/data/publication'
+import { bundleSchema } from '../../src/lib/data/schema'
+import type { DataBundle } from '../../src/lib/data/types'
+import {
+ isCatalogReconciliationComplete,
+ loadSourceManifestFiles,
+ validateSourceManifests,
+ type SourceManifestRecord,
+ type SourceManifestV2,
+} from '../source-manifest-registry'
+import {
+ buildCurrentSourceManifestCohort,
+ type CandidateManifestFile,
+ type SourceManifestCohortBuild,
+} from './build-source-manifest-cohort'
+
+export const SOURCE_MANIFEST_TRUST_LEDGER_STATUSES = [
+ 'complete',
+ 'in_progress',
+ 'limited_official_catalog',
+] as const
+
+export type SourceManifestTrustLedgerStatus =
+ (typeof SOURCE_MANIFEST_TRUST_LEDGER_STATUSES)[number]
+
+export const SOURCE_MANIFEST_UPGRADE_STAGES = [
+ 'reconciled_complete',
+ 'formal_v2_reconciliation_required',
+ 'legacy_v1_upgrade_required',
+ 'candidate_review_required',
+ 'limited_catalog_review_required',
+ 'official_source_discovery_required',
+] as const
+
+export type SourceManifestUpgradeStage =
+ (typeof SOURCE_MANIFEST_UPGRADE_STAGES)[number]
+
+export type SourceManifestLedgerUniversity = {
+ id: string
+ slug: string
+ name: {
+ en: string | null
+ zh: string | null
+ }
+}
+
+export type SourceManifestTrustLedgerEntry = {
+ institutionId: string
+ slug: string
+ name: SourceManifestLedgerUniversity['name']
+ status: SourceManifestTrustLedgerStatus
+ upgradeStage: SourceManifestUpgradeStage
+ formalManifest: {
+ registered: boolean
+ version: 1 | 2 | null
+ state: 'not_registered' | 'legacy_v1' | 'in_progress' | 'complete'
+ checkedAt: string | null
+ }
+ candidate: {
+ available: boolean
+ classification:
+ | 'none'
+ | 'catalog_linked_candidate'
+ | 'limited_official_catalog_candidate'
+ cohortId: string | null
+ fileName: string | null
+ scope: SourceManifestV2['catalogReconciliation']['scope'] | null
+ sourceCount: number
+ reconciliationEntryCount: number
+ safelyDisabled: boolean
+ }
+ reconciliation: {
+ state:
+ | 'complete'
+ | 'in_progress'
+ | 'legacy_v1_not_reconciled'
+ | 'candidate_is_not_reconciliation'
+ | 'not_started'
+ basis: 'formal_v2' | 'legacy_v1' | 'candidate_only' | 'none'
+ }
+ gates: {
+ publicationEligible: boolean
+ candidateEvidenceOnly: boolean
+ requiresHumanReview: boolean
+ }
+}
+
+export type SourceManifestTrustLedgerReport = {
+ format: 'studyinchina.source-manifest-trust-ledger'
+ formatVersion: 1
+ checkedAt: string
+ scope: 'public_catalog'
+ disposition: 'audit_only'
+ policy: {
+ publicScope: string
+ candidateBoundary: string
+ legacyBoundary: string
+ formalImportGate: string
+ }
+ summary: {
+ publicUniversities: number
+ ledgerEntries: number
+ statusCounts: Record
+ formalManifestRecords: number
+ formalRecordsOutsidePublicCatalog: number
+ legacyV1UpgradePaths: number
+ formalV2InProgress: number
+ completeFormalReconciliations: number
+ candidateCoverage: number
+ candidateRecordsOutsidePublicCatalog: number
+ candidateOnlyRecords: number
+ formalCandidateOverlap: number
+ officialSourceDiscoveryRequired: number
+ candidateCohort: {
+ cohortId: string
+ officialTargets: number
+ militaryExcluded: number
+ eligibleTargets: number
+ candidateManifests: number
+ catalogLinkedCandidates: number
+ limitedOfficialCatalogCandidates: number
+ exactOfficialHttpsSources: number
+ }
+ }
+ entries: SourceManifestTrustLedgerEntry[]
+}
+
+export type BuildSourceManifestTrustLedgerInput = {
+ checkedAt: string
+ publicUniversities: SourceManifestLedgerUniversity[]
+ formalManifests: SourceManifestRecord[]
+ candidateBuild: SourceManifestCohortBuild
+}
+
+type SourceManifestTrustLedgerCli = {
+ checkedAt: string
+ outputPath?: string
+}
+
+const CLI_USAGE = [
+ 'Usage:',
+ ' --checked-at ',
+ ' --checked-at --output ',
+].join('\n')
+
+function assertUniqueMap(
+ values: T[],
+ keyOf: (value: T) => string,
+ label: string,
+): Map {
+ const result = new Map()
+ for (const value of values) {
+ const key = keyOf(value)
+ if (!key) throw new Error(`${label} contains an empty identity`)
+ if (result.has(key)) throw new Error(`${label} repeats ${key}`)
+ result.set(key, value)
+ }
+ return result
+}
+
+function candidateIsSafelyDisabled(candidate: CandidateManifestFile): boolean {
+ return candidate.manifest.manifestStatus === 'in_progress'
+ && candidate.manifest.catalogReconciliation.status === 'in_progress'
+ && candidate.manifest.catalogReconciliation.entries.every(
+ (entry) => entry.status === 'pending',
+ )
+ && candidate.manifest.sources.every(
+ (source) => source.enabled === false && source.robots.mode === 'blocked',
+ )
+}
+
+function statusFor(
+ formal: SourceManifestRecord | undefined,
+ candidate: CandidateManifestFile | undefined,
+): SourceManifestTrustLedgerStatus {
+ if (formal && isCatalogReconciliationComplete(formal)) return 'complete'
+ const formalIsLimited = formal?.version === 2
+ && formal.catalogReconciliation.scope === 'limited_official_catalog'
+ const candidateIsLimited = candidate?.manifest.catalogReconciliation.scope
+ === 'limited_official_catalog'
+ return formalIsLimited || candidateIsLimited
+ ? 'limited_official_catalog'
+ : 'in_progress'
+}
+
+function upgradeStageFor(
+ formal: SourceManifestRecord | undefined,
+ candidate: CandidateManifestFile | undefined,
+ status: SourceManifestTrustLedgerStatus,
+): SourceManifestUpgradeStage {
+ if (status === 'complete') return 'reconciled_complete'
+ if (formal?.version === 2) return 'formal_v2_reconciliation_required'
+ if (formal?.version === 1) return 'legacy_v1_upgrade_required'
+ if (status === 'limited_official_catalog') {
+ return 'limited_catalog_review_required'
+ }
+ if (candidate) return 'candidate_review_required'
+ return 'official_source_discovery_required'
+}
+
+function reconciliationFor(
+ formal: SourceManifestRecord | undefined,
+ candidate: CandidateManifestFile | undefined,
+): SourceManifestTrustLedgerEntry['reconciliation'] {
+ if (formal?.version === 2) {
+ return {
+ state: isCatalogReconciliationComplete(formal) ? 'complete' : 'in_progress',
+ basis: 'formal_v2',
+ }
+ }
+ if (formal?.version === 1) {
+ return { state: 'legacy_v1_not_reconciled', basis: 'legacy_v1' }
+ }
+ if (candidate) {
+ return { state: 'candidate_is_not_reconciliation', basis: 'candidate_only' }
+ }
+ return { state: 'not_started', basis: 'none' }
+}
+
+function formalManifestFor(
+ formal: SourceManifestRecord | undefined,
+): SourceManifestTrustLedgerEntry['formalManifest'] {
+ if (!formal) {
+ return {
+ registered: false,
+ version: null,
+ state: 'not_registered',
+ checkedAt: null,
+ }
+ }
+ if (formal.version === 1) {
+ return {
+ registered: true,
+ version: 1,
+ state: 'legacy_v1',
+ checkedAt: formal.checkedAt,
+ }
+ }
+ return {
+ registered: true,
+ version: 2,
+ state: isCatalogReconciliationComplete(formal) ? 'complete' : 'in_progress',
+ checkedAt: formal.checkedAt,
+ }
+}
+
+export function buildSourceManifestTrustLedger(
+ input: BuildSourceManifestTrustLedgerInput,
+): SourceManifestTrustLedgerReport {
+ const publicUniversityById = assertUniqueMap(
+ input.publicUniversities,
+ (university) => university.id,
+ 'Public university catalog',
+ )
+ const formalByInstitution = assertUniqueMap(
+ input.formalManifests,
+ (manifest) => manifest.institutionId,
+ 'Formal source manifests',
+ )
+ const candidateByInstitution = assertUniqueMap(
+ input.candidateBuild.candidates,
+ (candidate) => candidate.manifest.institutionId,
+ 'Source-manifest candidate cohort',
+ )
+
+ for (const candidate of candidateByInstitution.values()) {
+ if (!candidateIsSafelyDisabled(candidate)) {
+ throw new Error(
+ `Candidate ${candidate.manifest.institutionId} is not safely disabled and pending`,
+ )
+ }
+ }
+
+ const entries = [...publicUniversityById.values()]
+ .sort((left, right) => left.id.localeCompare(right.id, 'en'))
+ .map((university): SourceManifestTrustLedgerEntry => {
+ const formal = formalByInstitution.get(university.id)
+ const candidate = candidateByInstitution.get(university.id)
+ const status = statusFor(formal, candidate)
+ const upgradeStage = upgradeStageFor(formal, candidate, status)
+ const complete = status === 'complete'
+ const candidateScope = candidate?.manifest.catalogReconciliation.scope ?? null
+ const safelyDisabled = candidate ? candidateIsSafelyDisabled(candidate) : false
+ return {
+ institutionId: university.id,
+ slug: university.slug,
+ name: university.name,
+ status,
+ upgradeStage,
+ formalManifest: formalManifestFor(formal),
+ candidate: {
+ available: Boolean(candidate),
+ classification: !candidate
+ ? 'none'
+ : candidateScope === 'limited_official_catalog'
+ ? 'limited_official_catalog_candidate'
+ : 'catalog_linked_candidate',
+ cohortId: candidate ? input.candidateBuild.gapReport.cohortId : null,
+ fileName: candidate?.fileName ?? null,
+ scope: candidateScope,
+ sourceCount: candidate?.manifest.sources.length ?? 0,
+ reconciliationEntryCount:
+ candidate?.manifest.catalogReconciliation.entries.length ?? 0,
+ safelyDisabled,
+ },
+ reconciliation: reconciliationFor(formal, candidate),
+ gates: {
+ publicationEligible: complete,
+ candidateEvidenceOnly: Boolean(candidate) && !complete,
+ requiresHumanReview: !complete,
+ },
+ }
+ })
+
+ const statusCounts = Object.fromEntries(
+ SOURCE_MANIFEST_TRUST_LEDGER_STATUSES.map((status) => [
+ status,
+ entries.filter((entry) => entry.status === status).length,
+ ]),
+ ) as Record
+ const publicIds = new Set(publicUniversityById.keys())
+ const publicFormalRecords = input.formalManifests.filter(
+ (manifest) => publicIds.has(manifest.institutionId),
+ )
+ const publicCandidates = input.candidateBuild.candidates.filter(
+ (candidate) => publicIds.has(candidate.manifest.institutionId),
+ )
+ const formalCandidateOverlap = publicCandidates.filter(
+ (candidate) => formalByInstitution.has(candidate.manifest.institutionId),
+ ).length
+
+ return {
+ format: 'studyinchina.source-manifest-trust-ledger',
+ formatVersion: 1,
+ checkedAt: input.checkedAt,
+ scope: 'public_catalog',
+ disposition: 'audit_only',
+ policy: {
+ publicScope:
+ 'Every university visible under the production publication rules for checkedAt has exactly one ledger entry.',
+ candidateBoundary:
+ 'A disabled V2 candidate is discovery evidence only; it never counts as catalog reconciliation or publication approval.',
+ legacyBoundary:
+ 'A legacy V1 pilot manifest is an explicit V2 upgrade path and never counts as a complete reconciliation.',
+ formalImportGate:
+ 'Only a separately reviewed formal V2 manifest with complete coverage, complete reconciliation, no pending entries, and no audit-only markers may pass the existing promotion gate.',
+ },
+ summary: {
+ publicUniversities: entries.length,
+ ledgerEntries: entries.length,
+ statusCounts,
+ formalManifestRecords: publicFormalRecords.length,
+ formalRecordsOutsidePublicCatalog:
+ input.formalManifests.length - publicFormalRecords.length,
+ legacyV1UpgradePaths: entries.filter(
+ (entry) => entry.formalManifest.state === 'legacy_v1',
+ ).length,
+ formalV2InProgress: entries.filter(
+ (entry) => entry.formalManifest.version === 2
+ && entry.formalManifest.state === 'in_progress',
+ ).length,
+ completeFormalReconciliations: statusCounts.complete,
+ candidateCoverage: publicCandidates.length,
+ candidateRecordsOutsidePublicCatalog:
+ input.candidateBuild.candidates.length - publicCandidates.length,
+ candidateOnlyRecords: publicCandidates.length - formalCandidateOverlap,
+ formalCandidateOverlap,
+ officialSourceDiscoveryRequired: entries.filter(
+ (entry) => entry.upgradeStage === 'official_source_discovery_required',
+ ).length,
+ candidateCohort: {
+ cohortId: input.candidateBuild.gapReport.cohortId,
+ officialTargets: input.candidateBuild.summary.officialTargets,
+ militaryExcluded: input.candidateBuild.summary.militaryExcluded,
+ eligibleTargets: input.candidateBuild.summary.eligibleTargets,
+ candidateManifests: input.candidateBuild.summary.candidateManifests,
+ catalogLinkedCandidates:
+ input.candidateBuild.summary.catalogLinkedManifests,
+ limitedOfficialCatalogCandidates:
+ input.candidateBuild.summary.reconciliationFallbackManifests,
+ exactOfficialHttpsSources:
+ input.candidateBuild.summary.exactOfficialHttpsSources,
+ },
+ },
+ entries,
+ }
+}
+
+function loadCatalog(repositoryRoot: string): DataBundle {
+ const read = (name: string): unknown => JSON.parse(readFileSync(
+ resolve(repositoryRoot, 'content', 'data', `${name}.json`),
+ 'utf8',
+ )) as unknown
+ return bundleSchema.parse({
+ sources: read('sources'),
+ cities: read('cities'),
+ universities: read('universities'),
+ programs: read('programs'),
+ admissionCycles: read('admission-cycles'),
+ scholarships: read('scholarships'),
+ })
+}
+
+export function buildCurrentSourceManifestTrustLedger(
+ checkedAt: string,
+ repositoryRoot = resolve('.'),
+): SourceManifestTrustLedgerReport {
+ const root = resolve(repositoryRoot)
+ const publicCatalog = selectPublishedData(loadCatalog(root), checkedAt)
+ const formalManifestDirectory = resolve(root, 'content', 'source-manifests')
+ const formalManifests = validateSourceManifests(
+ loadSourceManifestFiles(formalManifestDirectory),
+ resolve(root, 'content', 'data', 'universities.json'),
+ )
+ const { build } = buildCurrentSourceManifestCohort(checkedAt, root)
+ return buildSourceManifestTrustLedger({
+ checkedAt,
+ publicUniversities: publicCatalog.universities.map((university) => ({
+ id: university.id,
+ slug: university.slug,
+ name: {
+ en: university.name.en ?? null,
+ zh: university.name.zh ?? null,
+ },
+ })),
+ formalManifests,
+ candidateBuild: build,
+ })
+}
+
+export function parseSourceManifestTrustLedgerCli(
+ argv: string[],
+): SourceManifestTrustLedgerCli {
+ let checkedAt: string | undefined
+ let outputPath: string | undefined
+ const seen = new Set()
+ for (let index = 0; index < argv.length; index += 1) {
+ const argument = argv[index]!
+ if (seen.has(argument)) {
+ throw new Error(`Duplicate CLI option: ${argument}\n${CLI_USAGE}`)
+ }
+ seen.add(argument)
+ if (argument !== '--checked-at' && argument !== '--output') {
+ throw new Error(`Unknown CLI option: ${argument}\n${CLI_USAGE}`)
+ }
+ const value = argv[index + 1]
+ if (!value || value.startsWith('--')) {
+ throw new Error(`Missing value for ${argument}\n${CLI_USAGE}`)
+ }
+ index += 1
+ if (argument === '--checked-at') checkedAt = value
+ else outputPath = value
+ }
+ if (!checkedAt || Number.isNaN(Date.parse(`${checkedAt}T00:00:00Z`))) {
+ throw new Error(`--checked-at requires a real YYYY-MM-DD date\n${CLI_USAGE}`)
+ }
+ if (new Date(`${checkedAt}T00:00:00Z`).toISOString().slice(0, 10) !== checkedAt) {
+ throw new Error(`--checked-at requires a real YYYY-MM-DD date\n${CLI_USAGE}`)
+ }
+ return { checkedAt, ...(outputPath ? { outputPath } : {}) }
+}
+
+function isInside(parent: string, child: string): boolean {
+ const path = relative(parent, child)
+ return path === ''
+ || (path !== '..' && !path.startsWith(`..${sep}`) && !isAbsolute(path))
+}
+
+export function writeSourceManifestTrustLedger(
+ report: SourceManifestTrustLedgerReport,
+ outputPath: string,
+ repositoryRoot = resolve('.'),
+): string {
+ const output = resolve(outputPath)
+ if (isInside(resolve(repositoryRoot), output)) {
+ throw new Error('Trust-ledger output must remain outside the repository')
+ }
+ if (existsSync(output)) {
+ throw new Error('Trust-ledger output must not overwrite an existing file')
+ }
+ mkdirSync(dirname(output), { recursive: true })
+ writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`, {
+ encoding: 'utf8',
+ flag: 'wx',
+ })
+ return output
+}
+
+function runCli(): void {
+ const options = parseSourceManifestTrustLedgerCli(process.argv.slice(2))
+ const repositoryRoot = resolve('.')
+ const report = buildCurrentSourceManifestTrustLedger(
+ options.checkedAt,
+ repositoryRoot,
+ )
+ if (options.outputPath) {
+ const output = writeSourceManifestTrustLedger(
+ report,
+ options.outputPath,
+ repositoryRoot,
+ )
+ process.stdout.write(`${JSON.stringify({
+ output,
+ checkedAt: report.checkedAt,
+ summary: report.summary,
+ })}\n`)
+ return
+ }
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
+}
+
+if (
+ process.argv[1]
+ && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
+) {
+ try {
+ runCli()
+ } catch (error) {
+ process.stderr.write(
+ `${error instanceof Error ? error.message : String(error)}\n`,
+ )
+ process.exitCode = 1
+ }
+}
diff --git a/scripts/operations/evaluate-p0-reliability.d.mts b/scripts/operations/evaluate-p0-reliability.d.mts
new file mode 100644
index 0000000..0119091
--- /dev/null
+++ b/scripts/operations/evaluate-p0-reliability.d.mts
@@ -0,0 +1,51 @@
+export type P0ReliabilityStatus = 'pass' | 'fail' | 'unobserved'
+
+export type P0ReliabilityCheck = {
+ id: string
+ status: P0ReliabilityStatus
+ source: string | null
+ observedAt: string | null
+ value: unknown
+ threshold: {
+ operator: string
+ value: number
+ unit: string
+ }
+ detail: string
+}
+
+export type P0ReliabilityAudit = {
+ format: string
+ formatVersion: 1
+ status: 'pass' | 'fail'
+ evaluatedAt: string
+ observation: {
+ formatValid: boolean
+ formatVersionValid: boolean
+ observedAt: string | null
+ }
+ thresholds: typeof P0_RELIABILITY_THRESHOLDS
+ summary: Record
+ checks: P0ReliabilityCheck[]
+}
+
+export const P0_OBSERVATION_FORMAT: string
+export const P0_AUDIT_FORMAT: string
+export const P0_RELIABILITY_THRESHOLDS: Readonly<{
+ observationMaxAgeMinutes: 15
+ backupMaxAgeHours: 26
+ releaseMaxAgeHours: 48
+ schedulerMaxAgeMinutes: 90
+ dlqMaxBacklogCount: 0
+ outboxMaxAgeHours: 168
+}>
+
+export function evaluateP0Reliability(
+ observation: unknown,
+ now?: string | number | Date,
+): P0ReliabilityAudit
+
+export function parseArguments(args: string[]): {
+ inputPath: string
+ outputPath: string | null
+}
diff --git a/scripts/operations/evaluate-p0-reliability.mjs b/scripts/operations/evaluate-p0-reliability.mjs
new file mode 100644
index 0000000..bc6372e
--- /dev/null
+++ b/scripts/operations/evaluate-p0-reliability.mjs
@@ -0,0 +1,356 @@
+#!/usr/bin/env node
+
+import { readFile, writeFile } from 'node:fs/promises'
+import { resolve } from 'node:path'
+import { pathToFileURL } from 'node:url'
+
+export const P0_OBSERVATION_FORMAT = 'studyinchina.p0-reliability-observations'
+export const P0_AUDIT_FORMAT = 'studyinchina.p0-reliability-audit'
+export const P0_RELIABILITY_THRESHOLDS = Object.freeze({
+ observationMaxAgeMinutes: 15,
+ backupMaxAgeHours: 26,
+ releaseMaxAgeHours: 48,
+ schedulerMaxAgeMinutes: 90,
+ dlqMaxBacklogCount: 0,
+ outboxMaxAgeHours: 168,
+})
+
+function isRecord(value) {
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value)
+}
+
+function source(value) {
+ if (typeof value !== 'string') return null
+ const normalized = value.trim()
+ if (!/^[a-z0-9][a-z0-9:._/-]{1,199}$/iu.test(normalized)) return null
+ return normalized
+}
+
+function timestamp(value) {
+ if (typeof value !== 'string') return null
+ const milliseconds = Date.parse(value)
+ if (!Number.isFinite(milliseconds)) return null
+ return { iso: new Date(milliseconds).toISOString(), milliseconds }
+}
+
+function rounded(value) {
+ return Math.round(value * 1_000) / 1_000
+}
+
+function unobserved(id, detail, threshold, evidenceSource = null) {
+ return {
+ id,
+ status: 'unobserved',
+ source: evidenceSource,
+ observedAt: null,
+ value: null,
+ threshold,
+ detail,
+ }
+}
+
+function ageCheck({
+ id,
+ section,
+ field,
+ nowMilliseconds,
+ divisor,
+ maximum,
+ threshold,
+}) {
+ if (!isRecord(section)) {
+ return unobserved(id, 'Required observation section is missing.', threshold)
+ }
+ const evidenceSource = source(section.source)
+ if (!evidenceSource) {
+ return unobserved(id, 'A bounded machine-readable observation source is required.', threshold)
+ }
+ const observed = timestamp(section[field])
+ if (!observed || observed.milliseconds > nowMilliseconds) {
+ return unobserved(
+ id,
+ 'The required observation timestamp is missing, invalid, or in the future.',
+ threshold,
+ evidenceSource,
+ )
+ }
+ const rawAge = (nowMilliseconds - observed.milliseconds) / divisor
+ const age = rounded(rawAge)
+ const passed = rawAge <= maximum
+ return {
+ id,
+ status: passed ? 'pass' : 'fail',
+ source: evidenceSource,
+ observedAt: observed.iso,
+ value: age,
+ threshold,
+ detail: passed
+ ? 'Observed value is inside the reliability threshold.'
+ : 'Observed value is older than the reliability threshold.',
+ }
+}
+
+function dlqCheck(section) {
+ const threshold = { operator: 'eq', value: P0_RELIABILITY_THRESHOLDS.dlqMaxBacklogCount, unit: 'messages' }
+ if (!isRecord(section)) {
+ return unobserved('dlq_backlog', 'Required DLQ observation is missing.', threshold)
+ }
+ const evidenceSource = source(section.source)
+ if (!evidenceSource) {
+ return unobserved(
+ 'dlq_backlog',
+ 'A bounded machine-readable observation source is required.',
+ threshold,
+ )
+ }
+ if (!Number.isSafeInteger(section.backlogCount) || section.backlogCount < 0) {
+ return unobserved(
+ 'dlq_backlog',
+ 'DLQ backlogCount must be a non-negative safe integer.',
+ threshold,
+ evidenceSource,
+ )
+ }
+ if (section.backlogCount === 0 && section.oldestMessageAt !== null) {
+ return unobserved(
+ 'dlq_backlog',
+ 'An empty DLQ must explicitly report oldestMessageAt as null.',
+ threshold,
+ evidenceSource,
+ )
+ }
+ const passed = section.backlogCount === 0
+ return {
+ id: 'dlq_backlog',
+ status: passed ? 'pass' : 'fail',
+ source: evidenceSource,
+ observedAt: null,
+ value: section.backlogCount,
+ threshold,
+ detail: passed
+ ? 'No unhandled DLQ messages were observed.'
+ : 'One or more unhandled DLQ messages were observed.',
+ }
+}
+
+function outboxCheck(section, nowMilliseconds) {
+ const threshold = {
+ operator: 'lt',
+ value: P0_RELIABILITY_THRESHOLDS.outboxMaxAgeHours,
+ unit: 'hours',
+ }
+ if (!isRecord(section)) {
+ return unobserved('outbox_backlog_age', 'Required outbox observation is missing.', threshold)
+ }
+ const evidenceSource = source(section.source)
+ if (!evidenceSource) {
+ return unobserved(
+ 'outbox_backlog_age',
+ 'A bounded machine-readable observation source is required.',
+ threshold,
+ )
+ }
+ if (!Number.isSafeInteger(section.backlogCount) || section.backlogCount < 0) {
+ return unobserved(
+ 'outbox_backlog_age',
+ 'Outbox backlogCount must be a non-negative safe integer.',
+ threshold,
+ evidenceSource,
+ )
+ }
+ if (section.backlogCount === 0) {
+ if (section.oldestPendingAt !== null) {
+ return unobserved(
+ 'outbox_backlog_age',
+ 'An empty outbox must explicitly report oldestPendingAt as null.',
+ threshold,
+ evidenceSource,
+ )
+ }
+ return {
+ id: 'outbox_backlog_age',
+ status: 'pass',
+ source: evidenceSource,
+ observedAt: null,
+ value: { backlogCount: 0, oldestAgeHours: null },
+ threshold,
+ detail: 'No pending outbox events were observed.',
+ }
+ }
+
+ const oldest = timestamp(section.oldestPendingAt)
+ if (!oldest || oldest.milliseconds > nowMilliseconds) {
+ return unobserved(
+ 'outbox_backlog_age',
+ 'A non-empty outbox requires a valid, non-future oldestPendingAt.',
+ threshold,
+ evidenceSource,
+ )
+ }
+ const rawAgeHours = (nowMilliseconds - oldest.milliseconds) / 3_600_000
+ const ageHours = rounded(rawAgeHours)
+ const passed = rawAgeHours < P0_RELIABILITY_THRESHOLDS.outboxMaxAgeHours
+ return {
+ id: 'outbox_backlog_age',
+ status: passed ? 'pass' : 'fail',
+ source: evidenceSource,
+ observedAt: oldest.iso,
+ value: { backlogCount: section.backlogCount, oldestAgeHours: ageHours },
+ threshold,
+ detail: passed
+ ? 'The oldest pending outbox event is inside the reliability threshold.'
+ : 'The oldest pending outbox event reached or exceeded the reliability threshold.',
+ }
+}
+
+export function evaluateP0Reliability(observation, now = new Date()) {
+ const evaluatedAt = new Date(now)
+ if (Number.isNaN(evaluatedAt.getTime())) {
+ throw new TypeError('Evaluation time must be a valid date.')
+ }
+ const nowMilliseconds = evaluatedAt.getTime()
+ const input = isRecord(observation) ? observation : {}
+ const contractValid = input.format === P0_OBSERVATION_FORMAT && input.formatVersion === 1
+ const checks = [
+ contractValid
+ ? {
+ id: 'input_contract',
+ status: 'pass',
+ source: 'observation-document',
+ observedAt: null,
+ value: { formatVersion: 1 },
+ threshold: { operator: 'eq', value: 1, unit: 'format-version' },
+ detail: 'Observation document uses the supported explicit contract.',
+ }
+ : unobserved(
+ 'input_contract',
+ 'Observation format or formatVersion is missing or unsupported.',
+ { operator: 'eq', value: 1, unit: 'format-version' },
+ 'observation-document',
+ ),
+ ageCheck({
+ id: 'observation_freshness',
+ section: { source: 'observation-document', observedAt: input.observedAt },
+ field: 'observedAt',
+ nowMilliseconds,
+ divisor: 60_000,
+ maximum: P0_RELIABILITY_THRESHOLDS.observationMaxAgeMinutes,
+ threshold: {
+ operator: 'lte',
+ value: P0_RELIABILITY_THRESHOLDS.observationMaxAgeMinutes,
+ unit: 'minutes',
+ },
+ }),
+ ageCheck({
+ id: 'backup_age',
+ section: input.backup,
+ field: 'lastVerifiedAt',
+ nowMilliseconds,
+ divisor: 3_600_000,
+ maximum: P0_RELIABILITY_THRESHOLDS.backupMaxAgeHours,
+ threshold: {
+ operator: 'lte',
+ value: P0_RELIABILITY_THRESHOLDS.backupMaxAgeHours,
+ unit: 'hours',
+ },
+ }),
+ ageCheck({
+ id: 'release_age',
+ section: input.release,
+ field: 'lastActivatedAt',
+ nowMilliseconds,
+ divisor: 3_600_000,
+ maximum: P0_RELIABILITY_THRESHOLDS.releaseMaxAgeHours,
+ threshold: {
+ operator: 'lte',
+ value: P0_RELIABILITY_THRESHOLDS.releaseMaxAgeHours,
+ unit: 'hours',
+ },
+ }),
+ ageCheck({
+ id: 'scheduler_heartbeat_age',
+ section: input.scheduler,
+ field: 'lastHeartbeatAt',
+ nowMilliseconds,
+ divisor: 60_000,
+ maximum: P0_RELIABILITY_THRESHOLDS.schedulerMaxAgeMinutes,
+ threshold: {
+ operator: 'lte',
+ value: P0_RELIABILITY_THRESHOLDS.schedulerMaxAgeMinutes,
+ unit: 'minutes',
+ },
+ }),
+ dlqCheck(input.dlq),
+ outboxCheck(input.outbox, nowMilliseconds),
+ ]
+ const summary = checks.reduce(
+ (counts, check) => ({ ...counts, [check.status]: counts[check.status] + 1 }),
+ { pass: 0, fail: 0, unobserved: 0 },
+ )
+ return {
+ format: P0_AUDIT_FORMAT,
+ formatVersion: 1,
+ status: summary.fail === 0 && summary.unobserved === 0 ? 'pass' : 'fail',
+ evaluatedAt: evaluatedAt.toISOString(),
+ observation: {
+ formatValid: input.format === P0_OBSERVATION_FORMAT,
+ formatVersionValid: input.formatVersion === 1,
+ observedAt: timestamp(input.observedAt)?.iso ?? null,
+ },
+ thresholds: P0_RELIABILITY_THRESHOLDS,
+ summary,
+ checks,
+ }
+}
+
+export function parseArguments(args) {
+ const options = { inputPath: null, outputPath: null }
+ for (let index = 0; index < args.length; index += 1) {
+ const flag = args[index]
+ if (flag !== '--input' && flag !== '--output') {
+ throw new Error(`Unknown argument: ${flag}`)
+ }
+ const value = args[index + 1]
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a path`)
+ const key = flag === '--input' ? 'inputPath' : 'outputPath'
+ if (options[key]) throw new Error(`${flag} may be provided only once`)
+ options[key] = value
+ index += 1
+ }
+ if (!options.inputPath) throw new Error('--input is required')
+ if (options.outputPath && resolve(options.outputPath) === resolve(options.inputPath)) {
+ throw new Error('--output must not overwrite the observation input')
+ }
+ return options
+}
+
+function isMainModule() {
+ const entry = process.argv[1]
+ return Boolean(entry && pathToFileURL(resolve(entry)).href === import.meta.url)
+}
+
+async function main() {
+ let options
+ let report
+ try {
+ options = parseArguments(process.argv.slice(2))
+ const observation = JSON.parse(await readFile(resolve(options.inputPath), 'utf8'))
+ report = evaluateP0Reliability(observation)
+ } catch {
+ report = evaluateP0Reliability(undefined)
+ }
+
+ const serialized = `${JSON.stringify(report, null, 2)}\n`
+ process.stdout.write(serialized)
+ if (options?.outputPath) {
+ await writeFile(resolve(options.outputPath), serialized, { encoding: 'utf8', flag: 'wx' })
+ }
+ if (report.status !== 'pass') process.exitCode = 1
+}
+
+if (isMainModule()) {
+ main().catch(() => {
+ process.stderr.write('P0 reliability audit could not emit a machine-readable report.\n')
+ process.exitCode = 1
+ })
+}
diff --git a/scripts/quality/platform-data-quality.ts b/scripts/quality/platform-data-quality.ts
index 4ba6ae8..048530e 100644
--- a/scripts/quality/platform-data-quality.ts
+++ b/scripts/quality/platform-data-quality.ts
@@ -3,6 +3,7 @@ import { writeFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { bundleSchema } from '../../src/lib/data/schema'
+import { getApplicationState } from '../../src/lib/data/admission'
import { getTodayDate } from '../../src/lib/data/freshness'
import { selectPublishedData } from '../../src/lib/data/publication'
import type { DataBundle } from '../../src/lib/data/types'
@@ -15,6 +16,8 @@ import {
export const FOUR_WEEK_QUALITY_THRESHOLDS = {
publicUniversities: 257,
schoolsBelowThreePrograms: 0,
+ freshDispositionCoveragePct: 70,
+ // Deprecated compatibility target; the gate uses freshDispositionCoveragePct.
currentCycleCoveragePct: 70,
durationCoveragePct: 90,
applicationUrlCoveragePct: 80,
@@ -38,7 +41,7 @@ export type QualityGate = {
}
export type PlatformDataQualityScorecard = {
- schemaVersion: 1
+ schemaVersion: 2
generatedAt: string
evaluatedForDate: string
metrics: {
@@ -52,8 +55,19 @@ export type PlatformDataQualityScorecard = {
programCoverage: {
schoolsBelowThreePrograms: number
schoolIdsBelowThreePrograms: string[]
+ programsWithVerifiedIdentity: number
+ identityCoveragePct: number
+ programsWithFreshDisposition: number
+ freshDispositionCoveragePct: number
+ programsWithDatedOrRollingCycle: number
+ datedOrRollingCoveragePct: number
+ programsActiveOrUpcoming: number
+ activeUpcomingCoveragePct: number
+ // Deprecated alias of programsWithDatedOrRollingCycle.
programsWithCurrentCycle: number
+ // Deprecated alias of datedOrRollingCoveragePct.
currentCycleCoveragePct: number
+ currentCycleCoverageSemantics: 'deprecated_alias_of_dated_or_rolling'
programsWithDuration: number
durationCoveragePct: number
programsWithApplicationUrl: number
@@ -112,6 +126,21 @@ function percent(numerator: number, denominator: number): number {
return Math.round((numerator / denominator) * 10_000) / 100
}
+function shiftIsoDate(value: string, days: number): string {
+ const date = new Date(`${value}T00:00:00.000Z`)
+ date.setUTCDate(date.getUTCDate() + days)
+ return date.toISOString().slice(0, 10)
+}
+
+function isDateFreeFeeReference(
+ cycle: DataBundle['admissionCycles'][number],
+): boolean {
+ return cycle.tuitionStatus === 'reference'
+ && cycle.opensOn === null
+ && cycle.closesOn === null
+ && cycle.dateStatus !== 'rolling'
+}
+
function allAuditedRecords(bundle: DataBundle) {
return [
...bundle.cities,
@@ -154,8 +183,24 @@ export function buildPlatformDataQualityScorecard(
.filter((university) => (programCounts.get(university.id) ?? 0) < 3)
.map((university) => university.id)
.sort()
- const programsWithCycles = new Set(
- publicBundle.admissionCycles.map((cycle) => cycle.programId),
+ const admissionCycles = publicBundle.admissionCycles.filter(
+ (cycle) => !isDateFreeFeeReference(cycle),
+ )
+ const freshDispositionCutoff = shiftIsoDate(today, -30)
+ const programsWithFreshDisposition = new Set(
+ admissionCycles
+ .filter((cycle) => cycle.verifiedAt >= freshDispositionCutoff && cycle.verifiedAt <= today)
+ .map((cycle) => cycle.programId),
+ )
+ const programsWithDatedOrRollingCycle = new Set(
+ admissionCycles
+ .filter((cycle) => cycle.dateStatus === 'rolling' || cycle.opensOn !== null || cycle.closesOn !== null)
+ .map((cycle) => cycle.programId),
+ )
+ const programsActiveOrUpcoming = new Set(
+ admissionCycles
+ .filter((cycle) => ['open', 'upcoming', 'rolling'].includes(getApplicationState(cycle, today)))
+ .map((cycle) => cycle.programId),
)
const programsWithDuration = publicBundle.programs.filter(
(program) => program.durationMonths !== null && program.durationMonths > 0,
@@ -219,8 +264,17 @@ export function buildPlatformDataQualityScorecard(
programCoverage: {
schoolsBelowThreePrograms: schoolIdsBelowThreePrograms.length,
schoolIdsBelowThreePrograms,
- programsWithCurrentCycle: programsWithCycles.size,
- currentCycleCoveragePct: percent(programsWithCycles.size, programTotal),
+ programsWithVerifiedIdentity: programTotal,
+ identityCoveragePct: percent(programTotal, programTotal),
+ programsWithFreshDisposition: programsWithFreshDisposition.size,
+ freshDispositionCoveragePct: percent(programsWithFreshDisposition.size, programTotal),
+ programsWithDatedOrRollingCycle: programsWithDatedOrRollingCycle.size,
+ datedOrRollingCoveragePct: percent(programsWithDatedOrRollingCycle.size, programTotal),
+ programsActiveOrUpcoming: programsActiveOrUpcoming.size,
+ activeUpcomingCoveragePct: percent(programsActiveOrUpcoming.size, programTotal),
+ programsWithCurrentCycle: programsWithDatedOrRollingCycle.size,
+ currentCycleCoveragePct: percent(programsWithDatedOrRollingCycle.size, programTotal),
+ currentCycleCoverageSemantics: 'deprecated_alias_of_dated_or_rolling',
programsWithDuration,
durationCoveragePct: percent(programsWithDuration, programTotal),
programsWithApplicationUrl,
@@ -262,7 +316,7 @@ export function buildPlatformDataQualityScorecard(
const checks = [
atLeast('publicUniversities', metrics.publicRecords.universities),
atMost('schoolsBelowThreePrograms', metrics.programCoverage.schoolsBelowThreePrograms),
- atLeast('currentCycleCoveragePct', metrics.programCoverage.currentCycleCoveragePct),
+ atLeast('freshDispositionCoveragePct', metrics.programCoverage.freshDispositionCoveragePct),
atLeast('durationCoveragePct', metrics.programCoverage.durationCoveragePct),
atLeast('applicationUrlCoveragePct', metrics.programCoverage.applicationUrlCoveragePct),
atLeast('teachingLanguageCoveragePct', metrics.programCoverage.teachingLanguageCoveragePct),
@@ -278,7 +332,7 @@ export function buildPlatformDataQualityScorecard(
const passed = checks.filter((check) => check.passed).length
return {
- schemaVersion: 1,
+ schemaVersion: 2,
generatedAt: options.generatedAt ?? new Date().toISOString(),
evaluatedForDate: today,
metrics,
@@ -332,7 +386,10 @@ export function conciseScorecardSummary(report: PlatformDataQualityScorecard): s
const { publicRecords, programCoverage, scholarships, cities, sourceManifests } = report.metrics
return [
`Data quality: ${publicRecords.universities} universities / ${publicRecords.programs} programs / ${publicRecords.scholarships} scholarships`,
- `current cycles ${programCoverage.currentCycleCoveragePct}%`,
+ `identities ${programCoverage.identityCoveragePct}%`,
+ `fresh dispositions ${programCoverage.freshDispositionCoveragePct}%`,
+ `dated/rolling ${programCoverage.datedOrRollingCoveragePct}%`,
+ `active/upcoming ${programCoverage.activeUpcomingCoveragePct}%`,
`scholarship schools ${scholarships.universitiesCovered}`,
`city coordinates ${cities.withCoordinates}/${publicRecords.cities}`,
`manifests ${sourceManifests.institutionsRegistered} (${sourceManifests.completedReconciliations} reconciled)`,
diff --git a/scripts/source-manifest-contract.ts b/scripts/source-manifest-contract.ts
new file mode 100644
index 0000000..6347f8a
--- /dev/null
+++ b/scripts/source-manifest-contract.ts
@@ -0,0 +1,97 @@
+import { z } from 'zod'
+
+import {
+ SOURCE_CATEGORIES,
+ sourceManifestSchema,
+} from '../workers/ingestion/src/manifest-schema'
+
+export const CATALOG_RECONCILIATION_STATUSES = [
+ 'published',
+ 'individual_application_unavailable',
+ 'discontinued',
+ 'not_announced',
+ 'source_unavailable',
+ 'pending',
+] as const
+
+export type CatalogReconciliationStatus =
+ (typeof CATALOG_RECONCILIATION_STATUSES)[number]
+
+const coverageStatusSchema = z.enum([
+ 'registered',
+ 'parser_pending',
+ 'source_unavailable',
+ 'discovery_pending',
+ 'officially_not_provided',
+])
+
+const coverageSchema = z.object({
+ sourceCategory: z.enum(SOURCE_CATEGORIES),
+ status: coverageStatusSchema,
+ sourceIds: z.array(z.string().min(1)).optional(),
+ note: z.string().min(1).optional(),
+}).strict()
+
+const checkedAtSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(
+ (value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)),
+ { message: 'checkedAt must be a real ISO calendar date' },
+)
+
+export const pilotSourceManifestSchema = z.object({
+ version: z.literal(1),
+ institutionId: z.string().min(1),
+ catalogStatus: z.enum(['existing', 'planned_addition']),
+ checkedAt: checkedAtSchema,
+ sources: z.array(sourceManifestSchema).min(1),
+ coverage: z.array(coverageSchema).length(SOURCE_CATEGORIES.length),
+}).strict()
+
+export type PilotSourceManifest = z.infer
+
+const reconciliationEntrySchema = z.object({
+ sourceId: z.string().min(1),
+ officialKey: z.string().min(1),
+ officialName: z.string().min(1),
+ entityType: z.enum(['program', 'scholarship']),
+ status: z.enum(CATALOG_RECONCILIATION_STATUSES),
+ recordId: z.string().min(1).optional(),
+ note: z.string().min(1).optional(),
+}).strict()
+
+const catalogReconciliationSchema = z.object({
+ scope: z.enum([
+ 'full_official_catalog',
+ 'representative_international_programs',
+ 'limited_official_catalog',
+ ]),
+ status: z.enum(['complete', 'in_progress']),
+ entries: z.array(reconciliationEntrySchema).min(1),
+ note: z.string().min(1).optional(),
+}).strict().superRefine((reconciliation, context) => {
+ if (reconciliation.scope === 'representative_international_programs'
+ && reconciliation.status === 'complete') {
+ context.addIssue({
+ code: z.ZodIssueCode.custom,
+ path: ['scope'],
+ message:
+ 'representative_international_programs cannot claim complete catalog reconciliation',
+ })
+ }
+})
+
+export const sourceManifestV2Schema = z.object({
+ version: z.literal(2),
+ institutionId: z.string().min(1),
+ catalogStatus: z.enum(['existing', 'planned_addition']),
+ manifestStatus: z.enum(['complete', 'in_progress']),
+ checkedAt: checkedAtSchema,
+ officialHosts: z.array(z.string().min(1)).min(1),
+ // Fetch workers still consume the strict V1 source contract. V2 is the
+ // institution-level trust envelope around those source manifests.
+ sources: z.array(sourceManifestSchema).min(1),
+ coverage: z.array(coverageSchema).length(SOURCE_CATEGORIES.length),
+ catalogReconciliation: catalogReconciliationSchema,
+}).strict()
+
+export type SourceManifestV2 = z.infer
+export type SourceManifestRecord = PilotSourceManifest | SourceManifestV2
diff --git a/scripts/source-manifest-registry.ts b/scripts/source-manifest-registry.ts
index ba9fb74..874fa55 100644
--- a/scripts/source-manifest-registry.ts
+++ b/scripts/source-manifest-registry.ts
@@ -1,10 +1,8 @@
import { readdirSync, readFileSync } from 'node:fs'
import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
-import { z } from 'zod'
import {
SOURCE_CATEGORIES,
- sourceManifestSchema,
} from '../workers/ingestion/src/manifest-schema'
import {
normalizeAllowedHost,
@@ -13,77 +11,25 @@ import {
import type { SourceCategory, SourceManifestV1 } from '../workers/ingestion/src/types'
import {
INSTITUTION_HOST_ALLOWLISTS,
- pilotSourceManifestSchema,
- type PilotSourceManifest,
} from './validate-source-manifests'
+import {
+ CATALOG_RECONCILIATION_STATUSES,
+ pilotSourceManifestSchema,
+ sourceManifestV2Schema,
+ type CatalogReconciliationStatus,
+ type SourceManifestRecord,
+ type SourceManifestV2,
+} from './source-manifest-contract'
-export const CATALOG_RECONCILIATION_STATUSES = [
- 'published',
- 'individual_application_unavailable',
- 'discontinued',
- 'not_announced',
- 'source_unavailable',
- 'pending',
-] as const
-
-export type CatalogReconciliationStatus =
- (typeof CATALOG_RECONCILIATION_STATUSES)[number]
-
-const coverageStatusSchema = z.enum([
- 'registered',
- 'parser_pending',
- 'source_unavailable',
- 'discovery_pending',
- 'officially_not_provided',
-])
-
-const coverageSchema = z.object({
- sourceCategory: z.enum(SOURCE_CATEGORIES),
- status: coverageStatusSchema,
- sourceIds: z.array(z.string().min(1)).optional(),
- note: z.string().min(1).optional(),
-}).strict()
-
-const checkedAtSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine(
- (value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)),
- { message: 'checkedAt must be a real ISO calendar date' },
-)
-
-const reconciliationEntrySchema = z.object({
- sourceId: z.string().min(1),
- officialKey: z.string().min(1),
- officialName: z.string().min(1),
- entityType: z.enum(['program', 'scholarship']),
- status: z.enum(CATALOG_RECONCILIATION_STATUSES),
- recordId: z.string().min(1).optional(),
- note: z.string().min(1).optional(),
-}).strict()
-
-export const sourceManifestV2Schema = z.object({
- version: z.literal(2),
- institutionId: z.string().min(1),
- catalogStatus: z.enum(['existing', 'planned_addition']),
- manifestStatus: z.enum(['complete', 'in_progress']),
- checkedAt: checkedAtSchema,
- officialHosts: z.array(z.string().min(1)).min(1),
- // Individual fetch manifests remain V1 because this is the format consumed
- // by the ingestion Worker. V2 describes the institution-level contract.
- sources: z.array(sourceManifestSchema).min(1),
- coverage: z.array(coverageSchema).length(SOURCE_CATEGORIES.length),
- catalogReconciliation: z.object({
- scope: z.enum([
- 'full_official_catalog',
- 'representative_international_programs',
- 'limited_official_catalog',
- ]),
- status: z.enum(['complete', 'in_progress']),
- entries: z.array(reconciliationEntrySchema).min(1),
- note: z.string().min(1).optional(),
- }).strict(),
-}).strict()
-
-export type SourceManifestV2 = z.infer
-export type SourceManifestRecord = PilotSourceManifest | SourceManifestV2
+export {
+ CATALOG_RECONCILIATION_STATUSES,
+ sourceManifestV2Schema,
+}
+export type {
+ CatalogReconciliationStatus,
+ SourceManifestRecord,
+ SourceManifestV2,
+}
export type LoadedSourceManifest = {
filePath: string
@@ -240,7 +186,10 @@ export function isCatalogReconciliationComplete(
record: SourceManifestRecord,
): boolean {
if (record.version !== 2) return false
- return record.manifestStatus === 'complete'
+ const terminalScope = record.catalogReconciliation.scope === 'full_official_catalog'
+ || record.catalogReconciliation.scope === 'limited_official_catalog'
+ return terminalScope
+ && record.manifestStatus === 'complete'
&& record.catalogReconciliation.status === 'complete'
&& !record.catalogReconciliation.entries.some((entry) => entry.status === 'pending')
}
diff --git a/scripts/validate-maintenance.mjs b/scripts/validate-maintenance.mjs
index 8d3af93..bab8bc7 100644
--- a/scripts/validate-maintenance.mjs
+++ b/scripts/validate-maintenance.mjs
@@ -30,6 +30,7 @@ const health = workflows.get('.github/workflows/data-health.yml')
const backup = workflows.get('.github/workflows/cloudflare-backup.yml')
const restore = workflows.get('.github/workflows/cloudflare-restore-drill.yml')
const alias = workflows.get('.github/workflows/vercel-production-alias.yml')
+const p0Reliability = await text('scripts/operations/evaluate-p0-reliability.mjs')
requirePattern(
refresh,
@@ -84,6 +85,39 @@ requirePattern(
/Verify read access to both remote D1 databases/,
'The backup must verify both remote D1 resources before export.',
)
+requirePattern(
+ backup,
+ /secrets\.CLOUDFLARE_D1_BACKUP_TOKEN/,
+ 'The backup must use the dedicated least-privilege backup token.',
+)
+requirePattern(
+ backup,
+ /studyinchina-backups\/backups\/daily\/\$day\/raw-v1\/catalog\.sql\.gz[\s\S]*studyinchina-backups\/backups\/monthly\/\$month\/raw-v1\/catalog\.sql\.gz/,
+ 'D1 backups must use the raw-v1 namespace in the private backup-only R2 bucket.',
+)
+requirePattern(
+ backup,
+ /--content-type="application\/gzip" --content-encoding="identity"/,
+ 'Compressed SQL objects must be stored as raw bytes without transparent content decoding.',
+)
+requirePattern(
+ restore,
+ /studyinchina-backups\/backups\/monthly\/\$BACKUP_MONTH\/raw-v1\/catalog\.sql\.gz[\s\S]*raw-v1\/pipeline\.sql\.gz[\s\S]*raw-v1\/sha256\.txt/,
+ 'Restore drills must read the versioned raw-v1 monthly backup set.',
+)
+requirePattern(
+ backup,
+ /Upload daily and monthly copies[\s\S]*Read back and cryptographically verify daily checkpoint[\s\S]*--phase artifacts/,
+ 'The backup must read back and cryptographically verify uploaded objects.',
+)
+requirePattern(
+ backup,
+ /Read back and cryptographically verify daily checkpoint[\s\S]*backups\/daily\/\$day\/raw-v1\/catalog\.sql\.gz[\s\S]*raw-v1\/pipeline\.sql\.gz[\s\S]*raw-v1\/sha256\.txt[\s\S]*--phase artifacts/,
+ 'The readback step must verify the same raw-v1 daily object set that was uploaded.',
+)
+if (/secrets\.CLOUDFLARE_API_TOKEN/u.test(backup)) {
+ throw new Error('The backup still reads the legacy generic Cloudflare token secret.')
+}
requirePattern(
backup,
/if:\s*\$\{\{ failure\(\) \}\}[\s\S]*does \*\*not\*\* satisfy/,
@@ -94,11 +128,77 @@ requirePattern(
/15 1,4,7,10 \*/,
'A quarterly restore drill is required.',
)
+requirePattern(
+ restore,
+ /environment:\s*cloudflare-restore-drill/,
+ 'Restore access must be protected by the cloudflare-restore-drill environment.',
+)
+requirePattern(
+ restore,
+ /secrets\.CLOUDFLARE_D1_RESTORE_TOKEN/,
+ 'Restore must use a credential distinct from daily backup.',
+)
+if (/secrets\.CLOUDFLARE_(?:API|D1_BACKUP)_TOKEN/u.test(restore)) {
+ throw new Error('Restore must not read the generic or daily backup token secret.')
+}
requirePattern(
alias,
/vercel@58\.0\.0 alias set/,
'Successful main deployments must support stable Vercel alias promotion.',
)
+requirePattern(
+ alias,
+ /Wait for successful CI on the exact deployment SHA[\s\S]*branch=main&event=push[\s\S]*\.head_sha == \$sha[\s\S]*\.conclusion == "success"/,
+ 'Vercel alias promotion must wait for successful CI on the exact deployment SHA.',
+)
+requirePattern(
+ alias,
+ /Reconfirm deployment SHA is still current main[\s\S]*\/git\/ref\/heads\/main[\s\S]*steps\.current\.outputs\.matches == 'true'/,
+ 'Vercel alias promotion must recheck current main after waiting for CI.',
+)
+requirePattern(
+ alias,
+ /concurrency:[\s\S]*group:\s*vercel-production-alias[\s\S]*cancel-in-progress:\s*false/,
+ 'Vercel alias promotion must serialize runs without cancelling an in-progress mutation.',
+)
+const aliasPromotionStart = alias.indexOf('- name: Promote stable production alias transaction and verify release API')
+if (aliasPromotionStart < 0) {
+ throw new Error('The fail-closed Vercel alias transaction is missing.')
+}
+const aliasPromotionBlock = alias.slice(aliasPromotionStart)
+requirePattern(
+ aliasPromotionBlock,
+ /alias list[\s\S]*rollback_on_failure\(\)[\s\S]*vercel@58\.0\.0 alias set[\s\S]*previous_target[\s\S]*trap rollback_on_failure EXIT[\s\S]*previous_target=[\s\S]*final_main_sha=[\s\S]*mutation_attempted=true[\s\S]*vercel@58\.0\.0 alias set[\s\S]*DEPLOYMENT_URL[\s\S]*post_promotion_main_sha=[\s\S]*studyinchina\.vercel\.app\/api\/v1\/releases\/current[\s\S]*transaction_committed=true/,
+ 'Alias mutation must capture the previous target, recheck main immediately, and retain a rollback path.',
+)
+requirePattern(
+ aliasPromotionBlock,
+ /\/git\/ref\/heads\/main/,
+ 'Alias mutation must re-read the current main SHA inside the transaction.',
+)
+requirePattern(
+ aliasPromotionBlock,
+ /Production promotion raced with main/,
+ 'Alias mutation must fail closed when main advances during promotion.',
+)
+requirePattern(
+ aliasPromotionBlock,
+ /Stable alias rollback failed[\s\S]*Stable alias rollback verification failed/,
+ 'Alias mutation must retain explicit rollback and rollback-verification failure paths.',
+)
+if ((aliasPromotionBlock.match(/vercel@58\.0\.0 alias set/gu) ?? []).length !== 2) {
+ throw new Error('The alias mutation step must contain one promotion and one rollback command.')
+}
+requirePattern(
+ alias,
+ /\.data\.deploymentSha == \$sha[\s\S]*Promote stable production alias[\s\S]*\.data\.deploymentSha == \$sha/,
+ 'Immutable and stable release smokes must prove the exact deployment SHA.',
+)
+requirePattern(
+ aliasPromotionBlock,
+ /\.data\.publicCounts\.programs \| type == "number" and \. > 0/,
+ 'The stable release smoke must reject an empty or non-numeric public program count.',
+)
requirePattern(
alias,
/api\/v1\/releases\/current/,
@@ -109,6 +209,27 @@ requirePattern(
/VERCEL_TOKEN is not configured[\s\S]*exit 1/,
'A missing Vercel token must fail the alias workflow instead of producing a false green result.',
)
+requirePattern(
+ p0Reliability,
+ /studyinchina\.p0-reliability-observations/,
+ 'The P0 reliability audit must consume an explicit observation document.',
+)
+requirePattern(
+ p0Reliability,
+ /backupMaxAgeHours:\s*26[\s\S]*releaseMaxAgeHours:\s*48[\s\S]*schedulerMaxAgeMinutes:\s*90[\s\S]*dlqMaxBacklogCount:\s*0[\s\S]*outboxMaxAgeHours:\s*168/,
+ 'The P0 reliability audit thresholds must remain fail-closed and reviewable.',
+)
+requirePattern(
+ p0Reliability,
+ /status:\s*'unobserved'[\s\S]*summary\.fail === 0 && summary\.unobserved === 0/,
+ 'Missing reliability observations must fail the overall audit.',
+)
+if (/\bfetch\s*\(|node:https|node:http|https?:\/\//u.test(p0Reliability)) {
+ throw new Error('The P0 reliability evaluator must not contain a network access path.')
+}
+if (/process\.env/u.test(p0Reliability)) {
+ throw new Error('The P0 reliability evaluator must not read environment secrets.')
+}
const programs = JSON.parse(await text('content/data/programs.json'))
const currentPrograms = programs.filter(
@@ -133,6 +254,7 @@ console.log(JSON.stringify({
scheduledUrlLimit,
freshnessAudit: 'daily',
factRefresh: 'weekly',
- backup: 'daily',
- restoreDrill: 'quarterly',
+ backup: 'daily-with-readback',
+ restoreDrill: 'quarterly-protected',
+ productionPromotion: 'exact-sha-ci-gated',
}, null, 2))
diff --git a/scripts/validate-source-manifests.ts b/scripts/validate-source-manifests.ts
index 866acbc..6e48878 100644
--- a/scripts/validate-source-manifests.ts
+++ b/scripts/validate-source-manifests.ts
@@ -1,7 +1,6 @@
import { readdirSync, readFileSync } from 'node:fs'
import { basename, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
-import { z } from 'zod'
import {
SOURCE_CATEGORIES,
sourceManifestSchema,
@@ -11,8 +10,17 @@ import type {
SourceCategory,
SourceManifestV1,
} from '../workers/ingestion/src/types'
+import {
+ pilotSourceManifestSchema,
+ sourceManifestV2Schema,
+ type PilotSourceManifest,
+ type SourceManifestRecord,
+ type SourceManifestV2,
+} from './source-manifest-contract'
export { SOURCE_CATEGORIES, sourceManifestSchema }
+export { pilotSourceManifestSchema }
+export type { PilotSourceManifest, SourceManifestRecord, SourceManifestV2 }
const EXPECTED_CATALOG_STATUS = {
'uni-tsinghua-university': 'existing',
@@ -78,39 +86,6 @@ export const INSTITUTION_HOST_ALLOWLISTS: Record<
],
}
-const coverageSchema = z
- .object({
- sourceCategory: z.enum(SOURCE_CATEGORIES),
- status: z.enum([
- 'registered',
- 'parser_pending',
- 'source_unavailable',
- 'discovery_pending',
- 'officially_not_provided',
- ]),
- sourceIds: z.array(z.string().min(1)).optional(),
- note: z.string().min(1).optional(),
- })
- .strict()
-
-export const pilotSourceManifestSchema = z
- .object({
- version: z.literal(1),
- institutionId: z.string().min(1),
- catalogStatus: z.enum(['existing', 'planned_addition']),
- checkedAt: z
- .string()
- .regex(/^\d{4}-\d{2}-\d{2}$/)
- .refine((value) => !Number.isNaN(Date.parse(`${value}T00:00:00Z`)), {
- message: 'checkedAt must be a real ISO calendar date',
- }),
- sources: z.array(sourceManifestSchema).min(1),
- coverage: z.array(coverageSchema).length(SOURCE_CATEGORIES.length),
- })
- .strict()
-
-export type PilotSourceManifest = z.infer
-
export type LoadedPilotSourceManifest = {
filePath: string
value: unknown
@@ -135,14 +110,78 @@ export function loadPilotSourceManifestFiles(
})
}
+function validatePilotReconciliation(
+ record: SourceManifestV2,
+ filePath: string,
+ errors: string[],
+): void {
+ const sources = new Set(record.sources.map((source) => source.id))
+ const officialKeys = new Set()
+ for (const entry of record.catalogReconciliation.entries) {
+ if (officialKeys.has(entry.officialKey)) {
+ errors.push(errorMessage(
+ filePath,
+ `catalog reconciliation repeats officialKey ${entry.officialKey}`,
+ ))
+ }
+ officialKeys.add(entry.officialKey)
+ if (!sources.has(entry.sourceId)) {
+ errors.push(errorMessage(
+ filePath,
+ `catalog reconciliation references unknown source ${entry.sourceId}`,
+ ))
+ }
+ if (entry.status === 'published' && !entry.recordId) {
+ errors.push(errorMessage(
+ filePath,
+ `${entry.officialKey} published reconciliation requires recordId`,
+ ))
+ }
+ if (entry.status !== 'published' && entry.status !== 'pending' && !entry.note) {
+ errors.push(errorMessage(
+ filePath,
+ `${entry.officialKey} ${entry.status} reconciliation requires a note`,
+ ))
+ }
+ }
+ if (record.catalogReconciliation.status === 'complete'
+ && record.catalogReconciliation.entries.some((entry) => entry.status === 'pending')) {
+ errors.push(errorMessage(
+ filePath,
+ 'complete catalog reconciliation cannot contain pending entries',
+ ))
+ }
+ if (record.manifestStatus === 'complete') {
+ if (record.coverage.some((coverage) => coverage.status === 'discovery_pending')) {
+ errors.push(errorMessage(
+ filePath,
+ 'complete manifest cannot contain discovery_pending coverage',
+ ))
+ }
+ if (record.catalogReconciliation.status !== 'complete') {
+ errors.push(errorMessage(
+ filePath,
+ 'complete manifest requires complete catalog reconciliation',
+ ))
+ }
+ }
+}
+
export function validatePilotSourceManifests(
inputs: LoadedPilotSourceManifest[],
-): PilotSourceManifest[] {
+): SourceManifestRecord[] {
const errors: string[] = []
- const records: PilotSourceManifest[] = []
+ const records: SourceManifestRecord[] = []
for (const input of inputs) {
- const parsed = pilotSourceManifestSchema.safeParse(input.value)
+ const version = typeof input.value === 'object'
+ && input.value !== null
+ && 'version' in input.value
+ ? input.value.version
+ : undefined
+ const parsed = version === 2
+ ? sourceManifestV2Schema.safeParse(input.value)
+ : pilotSourceManifestSchema.safeParse(input.value)
if (!parsed.success) {
for (const issue of parsed.error.issues) {
errors.push(
@@ -209,6 +248,14 @@ export function validatePilotSourceManifests(
record.institutionId as keyof typeof EXPECTED_CATALOG_STATUS
],
)
+ if (record.version === 2) {
+ for (const host of record.officialHosts) {
+ if (!approvedHosts.has(host.toLowerCase())) {
+ errors.push(errorMessage(filePath, `declares unapproved official host ${host}`))
+ }
+ }
+ validatePilotReconciliation(record, filePath, errors)
+ }
const sourcesById = new Map(record.sources.map((source) => [source.id, source]))
const coverageByCategory = new Map()
@@ -435,7 +482,7 @@ export function validatePilotSourceManifests(
export function validatePilotSourceManifestDirectory(
directory?: string,
-): PilotSourceManifest[] {
+): SourceManifestRecord[] {
return validatePilotSourceManifests(loadPilotSourceManifestFiles(directory))
}
diff --git a/src/app/[locale]/cities/page.tsx b/src/app/[locale]/cities/page.tsx
index f032e98..4381e4a 100644
--- a/src/app/[locale]/cities/page.tsx
+++ b/src/app/[locale]/cities/page.tsx
@@ -1,15 +1,29 @@
import { notFound } from 'next/navigation'
import { CityExplorer } from '@/components/features/CityExplorer'
import { PageHero, SectionHeading } from '@/components/ui'
+import { formatStudentCityTitle } from '@/i18n/home-experience'
import { getMessages } from '@/i18n/messages'
import {
parseCityExplorerSearchParams,
type CityExplorerSearchParams,
} from '@/lib/city-explorer'
import { getCatalogData, getData } from '@/lib/data/load'
-import { pageMetadata, requireLocale } from '@/lib/site'
+import { hasSearchParameters, pageMetadata, requireLocale } from '@/lib/site'
-export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale) || 'en'; const m = getMessages(locale); return pageMetadata(locale, m.cities.title, m.cities.intro, 'cities') }
+export async function generateMetadata({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams?: Promise
+}) {
+ const locale = requireLocale((await params).locale) || 'en'
+ const messages = getMessages(locale)
+ const parameterized = searchParams ? hasSearchParameters(await searchParams) : false
+ return pageMetadata(locale, messages.cities.title, messages.cities.intro, 'cities', {
+ indexable: !parameterized,
+ })
+}
export default async function CitiesPage({
params,
searchParams,
@@ -53,7 +67,7 @@ export default async function CitiesPage({
description={messages.cities.intro}
/>
-
+
}) {
@@ -14,4 +12,25 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
robots: { index: false, follow: true },
}
}
-export default async function FavoritesPage({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale); if (!locale) notFound(); const messages = getMessages(locale); const data = await getCatalogData(); return <>> }
+
+export default async function FavoritesPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>
+}) {
+ const locale = requireLocale((await params).locale)
+ if (!locale) notFound()
+ const messages = getMessages(locale)
+
+ return <>
+
+
+ >
+}
diff --git a/src/app/[locale]/page.tsx b/src/app/[locale]/page.tsx
index d234501..a307317 100644
--- a/src/app/[locale]/page.tsx
+++ b/src/app/[locale]/page.tsx
@@ -3,7 +3,7 @@ import { notFound } from 'next/navigation'
import { Badge, Card, LinkButton, PageHero, SectionHeading } from '@/components/ui'
import { CityConstellation } from '@/components/features/CityConstellation'
import { UniversityCard } from '@/components/features/RecordCards'
-import { getHomeExperienceCopy } from '@/i18n/home-experience'
+import { formatStudentCityTitle, getHomeExperienceCopy } from '@/i18n/home-experience'
import { getMessages } from '@/i18n/messages'
import { localize } from '@/lib/data/format'
import { classifyProgramField, programFieldTaxonomy } from '@/lib/data/fields'
@@ -28,7 +28,12 @@ export default async function HomePage({ params }: { params: Promise<{ locale: s
const sourceCheckLabel = latestSourceCheck
? new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeZone: 'UTC' }).format(new Date(`${latestSourceCheck}T00:00:00Z`))
: messages.common.unknown
- const pathwayHrefs = ['universities', 'programs', 'data-policy', 'guides'] as const
+ const pathwayHrefs = [
+ 'universities',
+ 'programs',
+ 'guides/verify-admissions-data',
+ 'programs?applicationState=open',
+ ] as const
return <>
- {messages.common.explore} →} />
+ {messages.common.explore} →} />
diff --git a/src/app/[locale]/programs/[slug]/page.tsx b/src/app/[locale]/programs/[slug]/page.tsx
index 078f79f..bc57455 100644
--- a/src/app/[locale]/programs/[slug]/page.tsx
+++ b/src/app/[locale]/programs/[slug]/page.tsx
@@ -31,10 +31,14 @@ export async function generateMetadata({ params }: { params: Promise<{ locale: s
const data = await getCatalogData()
const program = data.programs.find((item) => item.slug === slug)
if (!program) return {}
+ const university = data.universities.find((item) => item.id === program.universityId)
+ const title = university
+ ? `${localize(program.name, locale)} — ${localize(university.name, locale)}`
+ : localize(program.name, locale)
return pageMetadata(
locale,
- localize(program.name, locale),
+ title,
`${degreeLabels(locale)[program.degreeLevel]} · ${disciplineLabels(locale)[program.discipline]}`,
`programs/${slug}`,
{ indexable: isIndexableProgram(program, data.admissionCycles, getTodayDate()) },
diff --git a/src/app/[locale]/programs/page.tsx b/src/app/[locale]/programs/page.tsx
index 6e6b2ff..ea642d6 100644
--- a/src/app/[locale]/programs/page.tsx
+++ b/src/app/[locale]/programs/page.tsx
@@ -9,9 +9,22 @@ import {
queryProgramCatalogRepository,
type ProgramCatalogSearchParams,
} from '@/lib/program-catalog'
-import { pageMetadata, requireLocale } from '@/lib/site'
+import { hasSearchParameters, pageMetadata, requireLocale } from '@/lib/site'
-export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale) || 'en'; const m = getMessages(locale); return pageMetadata(locale, m.programs.title, m.programs.intro, 'programs') }
+export async function generateMetadata({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams?: Promise
+}) {
+ const locale = requireLocale((await params).locale) || 'en'
+ const messages = getMessages(locale)
+ const parameterized = searchParams ? hasSearchParameters(await searchParams) : false
+ return pageMetadata(locale, messages.programs.title, messages.programs.intro, 'programs', {
+ indexable: !parameterized,
+ })
+}
export default async function ProgramsPage({
params,
searchParams,
diff --git a/src/app/[locale]/scholarships/page.tsx b/src/app/[locale]/scholarships/page.tsx
index e5de360..12cc6fe 100644
--- a/src/app/[locale]/scholarships/page.tsx
+++ b/src/app/[locale]/scholarships/page.tsx
@@ -9,9 +9,22 @@ import {
queryScholarshipCatalogRepository,
type ScholarshipCatalogSearchParams,
} from '@/lib/scholarship-catalog'
-import { pageMetadata, requireLocale } from '@/lib/site'
+import { hasSearchParameters, pageMetadata, requireLocale } from '@/lib/site'
-export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) { const locale = requireLocale((await params).locale) || 'en'; const m = getMessages(locale); return pageMetadata(locale, m.scholarships.title, m.scholarships.intro, 'scholarships') }
+export async function generateMetadata({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams?: Promise
+}) {
+ const locale = requireLocale((await params).locale) || 'en'
+ const messages = getMessages(locale)
+ const parameterized = searchParams ? hasSearchParameters(await searchParams) : false
+ return pageMetadata(locale, messages.scholarships.title, messages.scholarships.intro, 'scholarships', {
+ indexable: !parameterized,
+ })
+}
export default async function ScholarshipsPage({
params,
searchParams,
diff --git a/src/app/[locale]/universities/page.tsx b/src/app/[locale]/universities/page.tsx
index 7a6dcf1..7863608 100644
--- a/src/app/[locale]/universities/page.tsx
+++ b/src/app/[locale]/universities/page.tsx
@@ -8,16 +8,24 @@ import {
queryUniversityCatalogRepository,
type UniversityCatalogSearchParams,
} from '@/lib/university-catalog'
-import { pageMetadata, requireLocale } from '@/lib/site'
+import { hasSearchParameters, pageMetadata, requireLocale } from '@/lib/site'
-export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
+export async function generateMetadata({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ locale: string }>
+ searchParams?: Promise
+}) {
const locale = requireLocale((await params).locale) || 'en'
const messages = getMessages(locale)
+ const parameterized = searchParams ? hasSearchParameters(await searchParams) : false
return pageMetadata(
locale,
messages.universities.title,
messages.universities.intro,
'universities',
+ { indexable: !parameterized },
)
}
diff --git a/src/app/api/v1/institutions/route.ts b/src/app/api/v1/institutions/route.ts
index 3f69b73..4b1c3bc 100644
--- a/src/app/api/v1/institutions/route.ts
+++ b/src/app/api/v1/institutions/route.ts
@@ -14,6 +14,7 @@ import {
type FieldMeta,
type InstitutionRecord,
} from '@/lib/catalog-api/types'
+import { deploymentShaFromEnvironment } from '@/lib/catalog-api/runtime'
import { getTodayDate } from '@/lib/data/freshness'
export const runtime = 'nodejs'
@@ -136,7 +137,11 @@ function envelope(
return {
data: page.items.map((item) => institutionRecord(item, today)),
meta: {
- release: page.release,
+ release: {
+ ...page.release,
+ catalogBackend: getCatalogRepository().mode,
+ deploymentSha: deploymentShaFromEnvironment(),
+ },
notice: AUTOMATED_COLLECTION_NOTICE,
pageSize: page.items.length,
nextCursor: page.nextCursor,
diff --git a/src/app/api/v1/programs/compare/route.ts b/src/app/api/v1/programs/compare/route.ts
new file mode 100644
index 0000000..a5097d5
--- /dev/null
+++ b/src/app/api/v1/programs/compare/route.ts
@@ -0,0 +1,35 @@
+import { compareCatalogPrograms } from '@/lib/catalog-api/runtime'
+import {
+ handleCatalogRequest,
+ InvalidQueryError,
+ ok,
+ stringParam,
+} from '@/lib/catalog-api/http'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+
+const PROGRAM_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,199}$/u
+const MAX_COMPARE_PROGRAMS = 4
+
+function programIds(params: URLSearchParams): string[] {
+ const raw = stringParam(params, 'ids', { maxLength: 804 })
+ if (!raw) throw new InvalidQueryError('ids is required.')
+ const ids = [...new Set(raw.split(',').map((id) => id.trim()).filter(Boolean))]
+ if (ids.length === 0 || ids.length > MAX_COMPARE_PROGRAMS) {
+ throw new InvalidQueryError(
+ `ids must contain between 1 and ${MAX_COMPARE_PROGRAMS} unique program ids.`,
+ )
+ }
+ if (ids.some((id) => !PROGRAM_ID_PATTERN.test(id))) {
+ throw new InvalidQueryError('ids contains an invalid program id.')
+ }
+ return ids
+}
+
+export function GET(request: Request) {
+ return handleCatalogRequest(async () => {
+ const ids = programIds(new URL(request.url).searchParams)
+ return ok(await compareCatalogPrograms(ids))
+ })
+}
diff --git a/src/app/api/v1/releases/current/route.ts b/src/app/api/v1/releases/current/route.ts
index 42d5bbc..9b8fd79 100644
--- a/src/app/api/v1/releases/current/route.ts
+++ b/src/app/api/v1/releases/current/route.ts
@@ -1,9 +1,9 @@
-import { getCatalogApiService } from '@/lib/catalog-api/runtime'
+import { getCurrentCatalogRelease } from '@/lib/catalog-api/runtime'
import { handleCatalogRequest, ok } from '@/lib/catalog-api/http'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export function GET() {
- return handleCatalogRequest(async () => ok((await getCatalogApiService()).getCurrentRelease()))
+ return handleCatalogRequest(async () => ok(await getCurrentCatalogRelease()))
}
diff --git a/src/components/features/FavoritesView.tsx b/src/components/features/FavoritesView.tsx
index 80a50e7..580fb3d 100644
--- a/src/components/features/FavoritesView.tsx
+++ b/src/components/features/FavoritesView.tsx
@@ -1,18 +1,77 @@
'use client'
-import { useMemo, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { Button, Card, EmptyState, LinkButton } from '@/components/ui'
import type { LaunchLocale } from '@/i18n/config'
import type { Messages } from '@/i18n/messages'
-import { selectAdmissionCycle } from '@/lib/data/admission'
+import type {
+ AdmissionCycleRecord,
+ ApiEnvelope,
+ ProgramRecord,
+} from '@/lib/catalog-api/types'
import { formatCny, formatDate, localize } from '@/lib/data/format'
import { degreeLabels, disciplineLabels, languageLabel } from '@/lib/data/labels'
-import type { AdmissionCycle, Program, University } from '@/lib/data/types'
import { MAX_COMPARE } from '@/lib/favorites'
import { useFavorites } from './useFavorites'
import { FavoriteButton } from './FavoriteButton'
-function tuitionLabel(cycle: AdmissionCycle | undefined, locale: LaunchLocale, messages: Messages) {
+type ComparisonItem = {
+ program: ProgramRecord
+ currentCycle: AdmissionCycleRecord | null
+ linkedScholarshipCount: number
+}
+
+type ComparisonResponse = ApiEnvelope<{
+ items: ComparisonItem[]
+ missingIds: string[]
+}>
+
+type FavoritesExperience = {
+ loadError: string
+ retry: string
+ unavailable: string
+ linkedScholarships: string
+}
+
+const experienceCopy: Record = {
+ en: { loadError: 'Saved program details could not be loaded.', retry: 'Try again', unavailable: 'No longer in the public catalogue', linkedScholarships: 'Related scholarships' },
+ zh: { loadError: '暂时无法载入收藏项目详情。', retry: '重新加载', unavailable: '已不在公开目录中', linkedScholarships: '关联奖学金' },
+ ru: { loadError: 'Не удалось загрузить данные сохранённых программ.', retry: 'Повторить', unavailable: 'Больше нет в открытом каталоге', linkedScholarships: 'Связанные стипендии' },
+ de: { loadError: 'Details zu gespeicherten Studiengängen konnten nicht geladen werden.', retry: 'Erneut versuchen', unavailable: 'Nicht mehr im öffentlichen Katalog', linkedScholarships: 'Verknüpfte Stipendien' },
+ fr: { loadError: 'Impossible de charger les programmes enregistrés.', retry: 'Réessayer', unavailable: 'N’est plus dans le catalogue public', linkedScholarships: 'Bourses associées' },
+ es: { loadError: 'No se pudieron cargar los programas guardados.', retry: 'Reintentar', unavailable: 'Ya no está en el catálogo público', linkedScholarships: 'Becas relacionadas' },
+}
+
+const PROGRAM_ID_PATTERN = /^[a-z0-9][a-z0-9:_-]{0,199}$/u
+
+function batches(values: T[], size: number): T[][] {
+ const result: T[][] = []
+ for (let index = 0; index < values.length; index += size) {
+ result.push(values.slice(index, index + size))
+ }
+ return result
+}
+
+function safeHttpsUrl(value: string | null | undefined): string | null {
+ if (!value) return null
+ try {
+ const url = new URL(value)
+ return url.protocol === 'https:' ? url.toString() : null
+ } catch {
+ return null
+ }
+}
+
+async function fetchComparison(ids: string[], signal: AbortSignal): Promise {
+ const response = await fetch(
+ `/api/v1/programs/compare?ids=${encodeURIComponent(ids.join(','))}`,
+ { headers: { Accept: 'application/json' }, signal },
+ )
+ if (!response.ok) throw new Error(`Comparison request failed with ${response.status}`)
+ return await response.json() as ComparisonResponse
+}
+
+function tuitionLabel(cycle: AdmissionCycleRecord | null, locale: LaunchLocale, messages: Messages) {
if (cycle?.tuitionCny == null) return messages.common.unknown
const periods = {
program: messages.programs.tuitionProgram,
@@ -24,23 +83,166 @@ function tuitionLabel(cycle: AdmissionCycle | undefined, locale: LaunchLocale, m
return `${formatCny(cycle.tuitionCny, locale, messages.common.unknown)} / ${periods[cycle.tuitionPeriod || 'other']}${cycle.tuitionStatus === 'reference' ? ` · ${messages.programs.tuitionReference}` : ''}`
}
-function durationLabel(program: Program, messages: Messages) {
+function durationLabel(program: ProgramRecord, messages: Messages) {
if (!program.durationMonths) return messages.common.unknown
return program.durationMonthsMax && program.durationMonthsMax !== program.durationMonths
? `${program.durationMonths}–${program.durationMonthsMax} ${messages.common.months}`
: `${program.durationMonths} ${messages.common.months}`
}
-export function FavoritesView({ programs, universities, cycles, locale, messages, today }: { programs: Program[]; universities: University[]; cycles: AdmissionCycle[]; locale: LaunchLocale; messages: Messages; today: string }) {
- const { favorites, ready } = useFavorites(); const [selected, setSelected] = useState([])
- const saved = useMemo(() => programs.filter((program) => favorites.includes(program.id)), [programs, favorites])
- const compared = saved.filter((program) => selected.includes(program.id))
+function applicationStateLabel(cycle: AdmissionCycleRecord | null, messages: Messages): string {
+ if (!cycle) return messages.programs.notAnnounced
+ const labels = {
+ open: messages.common.openNow,
+ upcoming: messages.programs.upcoming,
+ closed: messages.programs.applicationsClosed,
+ rolling: messages.programs.rolling,
+ 'dates-published': messages.programs.datePublished,
+ 'not-announced': messages.programs.notAnnounced,
+ 'previous-cycle': messages.programs.previousCycle,
+ }
+ return labels[cycle.applicationState]
+}
+
+export function FavoritesView({
+ locale,
+ messages,
+}: {
+ locale: LaunchLocale
+ messages: Messages
+}) {
+ const { favorites, ready } = useFavorites()
+ const [selected, setSelected] = useState([])
+ const [items, setItems] = useState([])
+ const [missingIds, setMissingIds] = useState([])
+ const [loading, setLoading] = useState(false)
+ const [loadError, setLoadError] = useState(false)
+ const [retryKey, setRetryKey] = useState(0)
+ const saved = useMemo(
+ () => items.filter(({ program }) => favorites.includes(program.id)),
+ [items, favorites],
+ )
+ const compared = saved.filter(({ program }) => selected.includes(program.id))
const copy = messages.favorites
- const toggleCompare = (id: string) => setSelected((current) => current.includes(id) ? current.filter((item) => item !== id) : current.length < MAX_COMPARE ? [...current, id] : current)
+ const experience = experienceCopy[locale]
+
+ useEffect(() => {
+ if (!ready || favorites.length === 0) return
+ const controller = new AbortController()
+
+ const load = async () => {
+ setLoading(true)
+ setLoadError(false)
+ const requestableIds = favorites.filter((id) => PROGRAM_ID_PATTERN.test(id))
+ const invalidIds = favorites.filter((id) => !PROGRAM_ID_PATTERN.test(id))
+ try {
+ const responses = await Promise.all(
+ batches(requestableIds, MAX_COMPARE)
+ .map((ids) => fetchComparison(ids, controller.signal)),
+ )
+ if (controller.signal.aborted) return
+ const returnedItems = responses.flatMap((response) => response.data.items)
+ const byId = new Map(returnedItems.map((item) => [item.program.id, item]))
+ setItems(favorites.flatMap((id) => byId.get(id) ? [byId.get(id)!] : []))
+ setMissingIds([
+ ...invalidIds,
+ ...favorites.filter((id) => !byId.has(id) && !invalidIds.includes(id)),
+ ])
+ } catch {
+ if (!controller.signal.aborted) setLoadError(true)
+ } finally {
+ if (!controller.signal.aborted) setLoading(false)
+ }
+ }
+
+ void load()
+ return () => controller.abort()
+ }, [favorites, ready, retryKey])
+
+ const toggleCompare = (id: string) => setSelected((current) => {
+ const available = current.filter((item) => favorites.includes(item))
+ if (available.includes(id)) return available.filter((item) => item !== id)
+ return available.length < MAX_COMPARE ? [...available, id] : available
+ })
+
if (!ready) return {copy.loading}
- if (!saved.length) return {messages.home.explorePrograms}} />
+ if (!favorites.length) return {messages.home.explorePrograms}} />
+ if (loadError) return {experience.loadError}
+ if (loading && saved.length === 0) return {copy.loading}
+
return
-
{copy.limit} {messages.favorites.localOnly}
{saved.map((program) => { const university = universities.find((item) => item.id === program.universityId); return
{localize(program.name, locale)}
{university ? localize(university.name, locale) : '—'}
{degreeLabels(locale)[program.degreeLevel]}{disciplineLabels(locale)[program.discipline]}
{messages.common.viewDetails}
})}
- {compared.length ?
{copy.comparison}
{compared.map((program) => { const university = universities.find((item) => item.id === program.universityId); const cycle = selectAdmissionCycle(cycles, program.id, today); return
{localize(program.name, locale)}
- {copy.university}
- {university ? localize(university.name, locale) : '—'}
- {messages.programs.degree}
- {degreeLabels(locale)[program.degreeLevel]}
- {messages.common.language}
- {program.teachingLanguages.length ? program.teachingLanguages.map((item) => languageLabel(item, locale)).join(', ') : messages.common.unknown}
- {messages.common.duration}
- {durationLabel(program, messages)}
- {messages.common.tuition}
- {tuitionLabel(cycle, locale, messages)}
- {messages.common.deadline}
- {formatDate(cycle?.closesOn ?? null, locale, messages.common.unknown)}
})}
: null}
+
+
{copy.limit} {messages.favorites.localOnly}
+
+ {saved.map(({ program }) =>
+
+ {localize(program.name, locale)}
+ {localize(program.university.name, locale)}
+
+ {degreeLabels(locale)[program.degreeLevel]}
+ {program.discipline ? disciplineLabels(locale)[program.discipline] : messages.common.unknown}
+
+
+ {messages.common.viewDetails}
+
+
+ )}
+ {missingIds.filter((id) => favorites.includes(id)).map((id) =>
+ {experience.unavailable}
+ {id}
+
+
+
+ )}
+
+
+ {compared.length ?
+
+
{copy.comparison}
+
+
+
+ {compared.map(({ program, currentCycle, linkedScholarshipCount }) => {
+ const canApply = currentCycle?.applicationState === 'open'
+ || currentCycle?.applicationState === 'rolling'
+ const applyHref = canApply ? safeHttpsUrl(program.applyUrl) : null
+ const sourceHref = currentCycle?.officialSources
+ .map((source) => safeHttpsUrl(source.url)).find(Boolean)
+ ?? program.officialSources.map((source) => safeHttpsUrl(source.url)).find(Boolean)
+ ?? safeHttpsUrl(program.programUrl)
+ const checkedAt = currentCycle?.officialSources[0]?.checkedAt
+ ?? program.officialSources[0]?.checkedAt
+ ?? program.verifiedAt
+
+ return
+ {localize(program.name, locale)}
+
+ - {copy.university}
- {localize(program.university.name, locale)}
+ - {messages.programs.degree}
- {degreeLabels(locale)[program.degreeLevel]}
+ - {messages.common.language}
- {program.teachingLanguages?.length ? program.teachingLanguages.map((item) => languageLabel(item, locale)).join(', ') : messages.common.unknown}
+ - {messages.common.duration}
- {durationLabel(program, messages)}
+ - {messages.programs.applicationStatus}
- {applicationStateLabel(currentCycle, messages)}
+ - {messages.common.tuition}
- {tuitionLabel(currentCycle, locale, messages)}
+ - {messages.programs.fee}
- {currentCycle?.applicationFeeCny == null ? messages.common.unknown : formatCny(currentCycle.applicationFeeCny, locale, messages.common.unknown)}
+ - {messages.common.deadline}
- {formatDate(currentCycle?.closesOn ?? null, locale, messages.common.unknown)}
+ - {experience.linkedScholarships}
- {linkedScholarshipCount.toLocaleString(locale)}
+ - {messages.common.lastVerified}
+
+
+
+ })}
+
+ : null}
}
diff --git a/src/components/features/ProgramExplorerV2.module.css b/src/components/features/ProgramExplorerV2.module.css
index 3544d10..bd5d026 100644
--- a/src/components/features/ProgramExplorerV2.module.css
+++ b/src/components/features/ProgramExplorerV2.module.css
@@ -3,6 +3,45 @@
position: static;
}
+.quickFilters {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: .55rem;
+ margin-bottom: 1rem;
+}
+
+.quickFilters > span {
+ margin-inline-end: .15rem;
+ color: var(--ink-soft);
+ font-size: .76rem;
+ font-weight: 800;
+ letter-spacing: .06em;
+ text-transform: uppercase;
+}
+
+.quickFilter {
+ padding: .48rem .78rem;
+ border: 1px solid var(--line-strong);
+ border-radius: 999px;
+ color: var(--jade-dark);
+ background: color-mix(in srgb, var(--paper-light) 84%, transparent);
+ font-size: .82rem;
+ font-weight: 800;
+ text-decoration: none;
+}
+
+.quickFilter:hover,
+.isActive {
+ border-color: var(--jade);
+ background: var(--atlas-jade-pale);
+}
+
+.quickFilter:focus-visible {
+ outline: 3px solid color-mix(in srgb, var(--jade) 28%, transparent);
+ outline-offset: 3px;
+}
+
.primaryGrid,
.advancedGrid {
display: grid;
diff --git a/src/components/features/ProgramExplorerV2.tsx b/src/components/features/ProgramExplorerV2.tsx
index 5b73245..bc21e89 100644
--- a/src/components/features/ProgramExplorerV2.tsx
+++ b/src/components/features/ProgramExplorerV2.tsx
@@ -1,3 +1,4 @@
+import Link from 'next/link'
import { Button, LinkButton } from '@/components/ui'
import type { LaunchLocale } from '@/i18n/config'
import type { Messages } from '@/i18n/messages'
@@ -25,13 +26,14 @@ const labels: Record = {
- en: { apply: 'Apply filters', defaultOrder: 'Default order', next: 'Next', pagination: 'Program catalogue pages', previous: 'Previous', sortBy: 'Sort by', linkedScholarship: 'Linked scholarship' },
- zh: { apply: '应用筛选', defaultOrder: '默认顺序', next: '下一页', pagination: '项目目录分页', previous: '上一页', sortBy: '排序方式', linkedScholarship: '有关联奖学金' },
- ru: { apply: 'Применить фильтры', defaultOrder: 'По умолчанию', next: 'Далее', pagination: 'Страницы каталога программ', previous: 'Назад', sortBy: 'Сортировка', linkedScholarship: 'Есть связанная стипендия' },
- de: { apply: 'Filter anwenden', defaultOrder: 'Standardreihenfolge', next: 'Weiter', pagination: 'Studiengangseiten', previous: 'Zurück', sortBy: 'Sortieren nach', linkedScholarship: 'Verknüpftes Stipendium' },
- fr: { apply: 'Appliquer les filtres', defaultOrder: 'Ordre par défaut', next: 'Suivant', pagination: 'Pages du catalogue des programmes', previous: 'Précédent', sortBy: 'Trier par', linkedScholarship: 'Bourse associée' },
- es: { apply: 'Aplicar filtros', defaultOrder: 'Orden predeterminado', next: 'Siguiente', pagination: 'Páginas del catálogo de programas', previous: 'Anterior', sortBy: 'Ordenar por', linkedScholarship: 'Beca vinculada' },
+ en: { apply: 'Apply filters', defaultOrder: 'Default order', next: 'Next', pagination: 'Program catalogue pages', previous: 'Previous', sortBy: 'Sort by', linkedScholarship: 'Linked scholarship', statusShortcuts: 'Application status shortcuts' },
+ zh: { apply: '应用筛选', defaultOrder: '默认顺序', next: '下一页', pagination: '项目目录分页', previous: '上一页', sortBy: '排序方式', linkedScholarship: '有关联奖学金', statusShortcuts: '申请状态快捷筛选' },
+ ru: { apply: 'Применить фильтры', defaultOrder: 'По умолчанию', next: 'Далее', pagination: 'Страницы каталога программ', previous: 'Назад', sortBy: 'Сортировка', linkedScholarship: 'Есть связанная стипендия', statusShortcuts: 'Быстрый выбор статуса заявки' },
+ de: { apply: 'Filter anwenden', defaultOrder: 'Standardreihenfolge', next: 'Weiter', pagination: 'Studiengangseiten', previous: 'Zurück', sortBy: 'Sortieren nach', linkedScholarship: 'Verknüpftes Stipendium', statusShortcuts: 'Schnellfilter für Bewerbungsstatus' },
+ fr: { apply: 'Appliquer les filtres', defaultOrder: 'Ordre par défaut', next: 'Suivant', pagination: 'Pages du catalogue des programmes', previous: 'Précédent', sortBy: 'Trier par', linkedScholarship: 'Bourse associée', statusShortcuts: 'Filtres rapides du statut de candidature' },
+ es: { apply: 'Aplicar filtros', defaultOrder: 'Orden predeterminado', next: 'Siguiente', pagination: 'Páginas del catálogo de programas', previous: 'Anterior', sortBy: 'Ordenar por', linkedScholarship: 'Beca vinculada', statusShortcuts: 'Filtros rápidos del estado de solicitud' },
}
type SelectOption = { value: string; label: string }
@@ -134,7 +136,29 @@ export function ProgramExplorerV2({
filters.sort === 'default' ? '' : filters.sort,
].filter(Boolean).length
+ const statusHref = (applicationState: 'open' | 'upcoming') => programCatalogHref(locale, {
+ ...filters,
+ applicationState,
+ page: 1,
+ cursor: '',
+ cursorHistory: [],
+ nextCursor: '',
+ }, 1)
+
return <>
+