fix(web): read migration filenames through a definer instead of app_runtime
Independent Staging Quality Gate / validate (push) Successful in 19m18s
Independent Staging Quality Gate / publish (push) Successful in 33m22s

Staging web never became healthy because /api/health selected migration.schema_migrations as app_runtime, which is forbidden, so Docker rolled the image back.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-26 20:21:49 +08:00
parent fa2b1be852
commit 60cbe8a75e
5 changed files with 69 additions and 13 deletions
+16
View File
@@ -5803,6 +5803,22 @@
- 复发自:BUG-394holdout 预留后未把门槛改成 training);BUG-395staging publish 被 holdout 类型和重复 re-export 挡住)
- 修复版本:待发布
## BUG-397 | staging web 因 health 直查 migration 账本而不健康
- 状态:resolved
- 首次发现:2026-08-26
- 最近更新:2026-08-26
- 影响面:`GET /api/health``deploy-staging.yml` run `2094``app_runtime` 权限、生时纠正 schema 合同
- 用户现象:quality gate run `2093` 成功后自动 Deploy staging。web 容器一直不健康,部署回滚。公网仍为上一成功 SHA `0511bc48`
- 触发条件:self-hosted web 用 `APP_DATABASE_URL``app_runtime`)跑健康检查;compose healthcheck 把非 2xx 当成失败。
- 根因:`app_runtime` 按 foundation 合同不得 `SELECT migration.schema_migrations`。health 直查该表后权限失败被标 `blocked`,接口 503Docker 判定 web unhealthy。
- 修复:新增 `20260826030000_rectification_migration_ledger_health.sql``SECURITY DEFINER` 函数 `rectification_schema_migration_filenames()` 只把文件名暴露给 `app_runtime`。health 改查该函数。账本可读且缺合同文件才 `blocked`/503;查询失败为 `degraded`,不把容器打挂。合同升到 `v4`。不把业务 SQL 拷进 `frontend/db/migrations`
- 验证:`frontend/tests/health-deployment.test.ts`。staging 必须先 `Migrate Staging Database``Deploy staging``GET /api/health``deployment.gitCommit` 等于本次 SHA,且 `database.requiredMigrationsPresent=true``rectificationContractVersion=v4`
- 防复发:health 不得直查 `migration.schema_migrations``app_runtime` 不得获得该表 SELECT。缺迁移仍由 checker 拦应用发布。不得只凭 `gitCommit` 声称迁移已执行。
- 相关记录:BUG-396、BUG-144、BUG-127
- 复发自:BUG-396(health 要证明迁移,但走了 `app_runtime` 禁止的账本查询)
- 修复版本:待发布
## BUG-379 | 生时纠正已记入学后仍编造高考年并再问入学
- 状态:resolved
+20 -10
View File
@@ -1,4 +1,5 @@
import { NextResponse } from "next/server";
import { Pool } from "pg";
import { getTruthSourceRuntimeIdentity } from "@/lib/truth-source-runtime-identity";
import { loadLanguageModelCatalog } from "@/lib/model-catalog";
import {
@@ -68,12 +69,17 @@ async function rectificationMigrationCheck(): Promise<{
return { check: { status: "blocked", message: "missing:APP_DATABASE_URL" }, database: empty };
}
const started = Date.now();
const { Client } = await import("pg");
const client = new Client({ connectionString: url, connectionTimeoutMillis: 2000 });
const pool = new Pool({
connectionString: url,
max: 1,
connectionTimeoutMillis: 2000,
idleTimeoutMillis: 1000,
allowExitOnIdle: true,
application_name: "jyotisha-health",
});
try {
await client.connect();
const result = await client.query<{ filename: string }>(
"select filename from migration.schema_migrations order by filename",
const result = await pool.query<{ filename: string }>(
"select filename from public.rectification_schema_migration_filenames()",
);
const database = databaseHealthFromFilenames(result.rows.map((row) => row.filename));
return {
@@ -89,14 +95,14 @@ async function rectificationMigrationCheck(): Promise<{
} catch (error) {
return {
check: {
status: "blocked",
status: "degraded",
message: error instanceof Error ? error.name : "database_migration_query_failed",
latencyMs: Date.now() - started,
},
database: empty,
};
} finally {
await client.end().catch(() => undefined);
await pool.end().catch(() => undefined);
}
}
@@ -132,13 +138,17 @@ export async function GET() {
supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]),
supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]),
};
const migrations = await rectificationMigrationCheck();
const [migrations, modelCatalog, jyotishApi] = await Promise.all([
rectificationMigrationCheck(),
modelCatalogCheck(),
jyotishApiCheck(),
]);
const checks = {
web: { status: "ok" } satisfies Check,
...databaseChecks,
modelProviderEncryption: envCheck(["MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY"]),
modelCatalog: await modelCatalogCheck(),
jyotishApi: await jyotishApiCheck(),
modelCatalog,
jyotishApi,
rectificationMigrations: migrations.check,
researchTruthSource: {
status: truthSourceIdentity.status,
+2 -1
View File
@@ -2,11 +2,12 @@
* Health contract for rectification schema. gitCommit proves the web image;
* latestMigration proves the business database actually applied the SQL.
*/
export const RECTIFICATION_CONTRACT_VERSION = "v3";
export const RECTIFICATION_CONTRACT_VERSION = "v4";
export const REQUIRED_RECTIFICATION_MIGRATIONS = [
"20260826010000_rectification_inference_round_audit.sql",
"20260826020000_rectification_choice_focus_identity.sql",
"20260826030000_rectification_migration_ledger_health.sql",
] as const;
export type DatabaseHealth = Readonly<{
@@ -0,0 +1,25 @@
-- app_runtime must not SELECT migration.schema_migrations (foundation privilege
-- contract). Health still needs the applied filenames, so expose a definer that
-- returns only those names. Business schema only; do not copy into
-- frontend/db/migrations (BUG-127 / BUG-144).
begin;
create or replace function public.rectification_schema_migration_filenames()
returns table(filename text)
language sql
stable
security definer
set search_path = ''
as $$
select m.filename
from migration.schema_migrations as m
order by m.filename;
$$;
revoke all on function public.rectification_schema_migration_filenames()
from public, anon, authenticated;
grant execute on function public.rectification_schema_migration_filenames()
to app_runtime;
commit;
+6 -2
View File
@@ -49,7 +49,8 @@ test("health endpoint exposes deployment identity for production verification",
assert.match(source, /rectificationMigrations/);
assert.match(contract, /20260826010000_rectification_inference_round_audit\.sql/);
assert.match(contract, /20260826020000_rectification_choice_focus_identity\.sql/);
assert.match(contract, /RECTIFICATION_CONTRACT_VERSION = "v3"/);
assert.match(contract, /20260826030000_rectification_migration_ledger_health\.sql/);
assert.match(contract, /RECTIFICATION_CONTRACT_VERSION = "v4"/);
assert.match(source, /loadLanguageModelCatalog/);
assert.match(source, /const defaults = catalog\.models\.filter/);
assert.match(source, /defaults\.length === 1/);
@@ -58,6 +59,9 @@ test("health endpoint exposes deployment identity for production verification",
assert.match(source, /: \{ status: "degraded", message \}/);
assert.match(source, /status: response\.ok \? "ok" : "blocked"/);
assert.match(source, /status === "blocked" \? 503 : 200/);
assert.match(source, /select filename from public\.rectification_schema_migration_filenames\(\)/);
assert.doesNotMatch(source, /select filename from migration\.schema_migrations/);
assert.doesNotMatch(source, /await import\("pg"\)/);
assert.doesNotMatch(source, /anyEnvCheck\(\["LLM_MODELS_JSON"|OPENAI_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY/);
});
@@ -70,7 +74,7 @@ test("health database contract requires the rectification identity migrations",
...REQUIRED_RECTIFICATION_MIGRATIONS,
]);
assert.equal(present.requiredMigrationsPresent, true);
assert.equal(present.latestMigration, REQUIRED_RECTIFICATION_MIGRATIONS[1]);
assert.equal(present.latestMigration, REQUIRED_RECTIFICATION_MIGRATIONS.at(-1));
});
test("GitHub mirror cannot deploy production", () => {