diff --git a/.github/workflows/phase4-gate5-g5a-post-disposable-pg16.yml b/.github/workflows/phase4-gate5-g5a-post-disposable-pg16.yml new file mode 100644 index 000000000..ce4eac405 --- /dev/null +++ b/.github/workflows/phase4-gate5-g5a-post-disposable-pg16.yml @@ -0,0 +1,94 @@ +name: Phase4 Gate5 G5A POST Ledger Disposable PG16 +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/phase4-gate5-g5a-post-disposable-pg16.yml" + - "scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts" + - "scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts" + - "tests/phase4_gate5_g5a_post_ledger_pg16.test.ts" + push: + branches: [main] + paths: + - ".github/workflows/phase4-gate5-g5a-post-disposable-pg16.yml" + - "scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts" + - "scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts" + - "tests/phase4_gate5_g5a_post_ledger_pg16.test.ts" +permissions: + contents: read +env: + ADB_AUTO_COLLECT: "0" + V39_PHASE6_PROCESS_ARMED: "0" + AERODATABOX_API_KEY: "" + DATABASE_URL: "postgresql://postgres:postgres@localhost:5432/v39_ci?sslmode=disable" + V39_DATABASE_RUNTIME_URL: "postgresql://g5_ci_runtime:g5_disposable_only@localhost:5432/v39_ci?sslmode=disable" + G5A_POST_TEST_WRITER_URL: "postgresql://p4_g5_g5a_owner_20261103:g5_post_disposable_only@localhost:5432/v39_ci?sslmode=disable" + G5A_POST_TEST_PG16: "1" +jobs: + post-ledger-pg16: + runs-on: ubuntu-latest + timeout-minutes: 12 + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: v39_ci + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d v39_ci" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: npm ci --include=dev --no-audit --no-fund + - name: Boot only disposable PG16 baseline and staged V0076 + run: | + set -Eeuo pipefail + node --import tsx scripts/apply_boot_migrations_v39.ts + psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 -f migrations/staged/V0076__phase4_gate5_logged_physical_fids_attempt_owner_v39.sql >/dev/null + - name: Prepare only disposable non-admin source writer and restricted app role + run: | + set -Eeuo pipefail + psql "$DATABASE_URL" -X -v ON_ERROR_STOP=1 <<'SQL' + CREATE ROLE g5_ci_runtime LOGIN PASSWORD 'g5_disposable_only'; + GRANT USAGE ON SCHEMA clean TO g5_ci_runtime; + CREATE ROLE p4_g5_g5a_owner_20261103 LOGIN + NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION NOBYPASSRLS + PASSWORD 'g5_post_disposable_only'; + GRANT USAGE ON SCHEMA clean TO p4_g5_g5a_owner_20261103; + GRANT SELECT,UPDATE ON TABLE clean.phase4_g5_fids_cohort_budget_v39 + TO p4_g5_g5a_owner_20261103; + GRANT SELECT ON TABLE clean.fids_query_response, + clean.flight_population,clean.flight_snapshots + TO p4_g5_g5a_owner_20261103; + INSERT INTO clean.phase4_g5_fids_cohort_budget_v39 + (manifest_sha256,cohort_id,project_unit_ceiling,reserved_units) + VALUES + (repeat('a',64),'WSSS_G5A_20261105_0200Z',24,18), + (repeat('b',64),'WSSS_G5A_20261105_0200Z_N',24,18), + (repeat('c',64),'WSSS_G5A_20261105_0200Z_K',24,24); + SQL + - name: Test actual one-time baseline attempt ledger GRANT (no other tables) + run: | + set -Eeuo pipefail + set +x + GITHUB_ACTIONS=false \ + P4_G5_G5A_POST_GRANT_INTENT=EXACT_G5A_POST_IMMUTABLE_LEDGER_INSERT_SELECT_NO_OTHER_PRIVILEGES \ + node --import tsx scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts + - name: Verify real immutable POST reservation, 24-unit cap and replay failure + run: | + set -Eeuo pipefail + npx vitest run tests/phase4_gate5_g5a_post_ledger_pg16.test.ts + echo 'POST_PROVIDER_CALLS=0' + echo 'PRODUCTION_DATABASE_WRITES=0' + echo 'GITHUB_PROVIDER_SECRETS_AVAILABLE=FALSE' diff --git a/.github/workflows/phase4-gate5-g5a-wsss-real-post.yml b/.github/workflows/phase4-gate5-g5a-wsss-real-post.yml new file mode 100644 index 000000000..0f13b7d64 --- /dev/null +++ b/.github/workflows/phase4-gate5-g5a-wsss-real-post.yml @@ -0,0 +1,83 @@ +name: Phase4 Gate5 G5A WSSS Real Prospective POST +on: + pull_request: + branches: [main] + paths: + - ".github/workflows/phase4-gate5-g5a-wsss-real-post.yml" + - "scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts" + - "scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts" + - "server/lib/disruption/fidsCensus_v3.ts" + - "tests/phase4_gate5_g5a_post_source_v39.test.ts" + push: + branches: [main] + paths: + - ".github/workflows/phase4-gate5-g5a-wsss-real-post.yml" + - "scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts" + - "scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts" + - "server/lib/disruption/fidsCensus_v3.ts" + - "tests/phase4_gate5_g5a_post_source_v39.test.ts" + schedule: + # Actual UTC date must be 2026-11-05; code refuses replay in future years + - cron: "10 2 5 11 *" +permissions: + contents: read + actions: read +concurrency: + group: gate5-g5a-wsss-20261105-post-real + cancel-in-progress: false +jobs: + offline-post: + if: ${{ github.event_name == 'pull_request' || github.event_name == 'push' }} + runs-on: ubuntu-latest + timeout-minutes: 9 + env: + AERODATABOX_API_KEY: "" + P4_G5_SOURCE_OWNER_DATABASE_URL: "" + V39_DATABASE_RUNTIME_URL: "" + DATABASE_URL: "" + GATE05_AUTOMATION_DATABASE_URL: "" + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: npm ci --include=dev --no-audit --no-fund + - name: Frozen G5A POST and source reservation guard tests, no provider + run: | + set -Eeuo pipefail + npx vitest run tests/phase4_gate5_g5a_post_source_v39.test.ts + echo 'PROVIDER_CALLS=0 DATABASE_CONTENT_WRITES=0 PAID_DISPATCH_DISABLED_ON_PR_PUSH' + post-source: + name: post-source + if: ${{ github.event_name == 'schedule' && github.event.schedule == '10 2 5 11 *' }} + runs-on: ubuntu-latest + timeout-minutes: 240 + environment: phase2g-paid + env: + ADB_AUTO_COLLECT: "0" + V39_PHASE6_PROCESS_ARMED: "0" + G5A_POST_SCHEDULE: "10 2 5 11 *" + G5A_POST_PAID_INTENT: G5A_NOV05_POST_THREE_2_UNIT_REQUESTS_MAX + AERODATABOX_API_KEY: ${{ secrets.AERODATABOX_API_KEY }} + P4_G5_SOURCE_OWNER_DATABASE_URL: ${{ secrets.P4_G5_SOURCE_OWNER_DATABASE_URL }} + V39_DATABASE_RUNTIME_URL: ${{ secrets.P4_G5_SOURCE_OWNER_DATABASE_URL }} + GITHUB_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: npm ci --include=dev --no-audit --no-fund + - name: Actual GitHub scheduled 3× bounded POST originals and T+2h label counts + run: | + set -Eeuo pipefail + set +x + test -z "${DATABASE_URL:-}${GATE05_AUTOMATION_DATABASE_URL:-}${RAPIDAPI_KEY:-}" + test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + node --import tsx scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts diff --git a/data-collection/phase4/2026-11-03_GATE5_G5A_PROSPECTIVE_POST_6_UNIT_SOURCE_AND_LABEL_MEASUREMENT.md b/data-collection/phase4/2026-11-03_GATE5_G5A_PROSPECTIVE_POST_6_UNIT_SOURCE_AND_LABEL_MEASUREMENT.md new file mode 100644 index 000000000..4ad79ae0f --- /dev/null +++ b/data-collection/phase4/2026-11-03_GATE5_G5A_PROSPECTIVE_POST_6_UNIT_SOURCE_AND_LABEL_MEASUREMENT.md @@ -0,0 +1,19 @@ +# Gate 5 G5A — separate real POST observation and provider-schedule revision label + +**Engineering checkpoint, 2026-11-03.** The binding owner's direction is rapid scientific measurement, not repeated vendor email/runway investigations. See first two links in Phase4 README. **This checkpoint does NOT declare source collection, actual POST, scientific PASS, or Phase5/Phase6 authorized.** + +The 2026-11-05 WSSS source cohort (main-merged #372) has 9 prospective PRE source slots at T−24h/T−6h/T−90m, 2 REST units/slot, 18-unit ceiling under **one 24-unit project cap**. This POST extension uses the remaining **maximum 6 units**, 3 one-attempt prospective FIDS GETs. It reobserves provider schedule changes and never calls them actual departure or runway delay. It does not add new DDL, and does not reinstall any phase schema. + +**Frozen POST source plan**: UTC 2026-11-05 **03:40, 04:10, 04:40**, corresponding to Nov4 **7:40, 8:10, 8:40 PM Pacific**. Source range WSSS UTC Nov5 **01:00–05:00** (Singapore local Nov5 09:00–13:00) is **four hours**, wider than the first one-hour cohort because retimed flights can move out of the original hour. This increases bias/coverage uncertainty but avoids conflating an absent row with an on-time flight. Frozen label watermark per physical flight: `W=first_native_schedule_T+2h`. Post-source availability must be prospectively recorded `A_post<=W` to count; source absent or after W is **UNKNOWN**. The provider's native ID + confirmed operating flight and route, **not mutable schedule or query window**, join reobservations. + +**GitHub-only prearm**: `Phase4 Gate5 G5A WSSS Real Prospective POST` scheduled 2026-11-05 **02:10 UTC** (Nov4 6:10pm Pacific), then sources above; result audit after 2026-11-05 **05:01 UTC** (Nov4 9:01pm Pacific). If GH schedule starts late, it must hold or mark missed slots, not retroactively make a response available before W. No PR/push manual/Cloudflare/Replit paid path. + +**Immutable budget accounting**: before *each* physical GET, PostgreSQL SERIALIZABLE transaction locks the existing `phase4_g5_fids_cohort_budget_v39` row, verifies frozen manifest 24-unit ceiling and sufficient balance, atomically increases its reserved-unit count by 2 and writes deterministic unique UUID to existing B0062 `clean.adb_rest_attempt_ledger` category `g5a_post_schedule_fids`, then **commits before GET**. Failed/ambiguous HTTP is never refunded/replayed. A failed earlier slot is **UNOBSERVED**, later POST can still proceed; no backfill. POST original decoded source, normalized rows and source hash are persisted via the reviewed `fidsCensus_v3.ts` path, with this exact reservation UUID included in its canonical reconstructed source envelope and batch ID. No source Content/flight ID printed in Actions. + +**One extra exact-privilege owner action, no extra secret**: the existing restricted source role `p4_g5_g5a_owner_20261103` was created successfully using #371. To use the existing baseline POST attempt ledger, **only** `GRANT SELECT, INSERT ON clean.adb_rest_attempt_ledger` is required. Reviewed one-time no-provider installer `scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts` is tested with disposable PG16, preserves existing no-DELETE/no-DDL and checks PUBLIC has no INSERT. It does not change the GitHub environment secret or the runtime role. Run exactly once only after reviewed PR merge and hash pin, never run source schema migrations again. + +**Measured outcome**: at W+1min, aggregate PRE snapshot source baseline and the latest qualified persisted POST observation as of W, by `horizon`: number of persisted PRE candidates, valid baseline, valid post, observed schedule revisions of absolute ≥15min, observed changes below threshold, **unknown/no qualified post**. No negative label is inferred from a missing reobservation. The audit uses fixed cohort manifest hash, source query UUID, immutable post reservation UUID and server `available_at`, stable physical ID, and exact 2h clock watermark. This is a **reported schedule-revision classification** with coverage/selection bias, not original strict actual wheels-off target. + +**Independent post-measurement work**: source+PRE audit from independent reader, callback inside/outside/unresolved, AIRBORNE coverage, source Content 23h/24h retention cleanup and actual billed units still require verification. The original strict runway-defined Gate5 remains HOLD, and G5A PASS_WITH_MEASURED_LIMITATIONS requires real independent measured evidence and a separately reviewed scientific verdict; do not infer it from CI or schedule existence. + +**OpenAPI source provenance**: use the actual Plan f.8 §5.1 pinned v1.15.3.0 document SHA `735620f2d2132c5bf51768f50caa767b7f0b25be8b128679641402666696890a` explicitly as historical documentation, *not* G5A protocol Markdown SHA or an unverified live provider schema version; source owner equivalent repair in #373. diff --git a/scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts b/scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts new file mode 100644 index 000000000..d284c2cb7 --- /dev/null +++ b/scripts/v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts @@ -0,0 +1,115 @@ +/** + * Optional one-time *metadata permission only* for the 3 G5A POST FIDS GETs. + * No provider HTTP, role creation, flight Content, new schema, or secret read. + * The source role already exists from merged #371, approved by the owner. + */ +import pg from "pg"; +import {execFileSync} from "node:child_process"; +const ROLE="p4_g5_g5a_owner_20261103"; +const TABLE="clean.adb_rest_attempt_ledger"; +const INTENT="EXACT_G5A_POST_IMMUTABLE_LEDGER_INSERT_SELECT_NO_OTHER_PRIVILEGES"; +const SHA=/^[a-f0-9]{40}$/; +function hold(msg:string):never{throw Error("P4_G5_G5A_POST_GRANT_"+msg+"_HOLD")} +export async function main(){ + const e=process.env; + if(e.P4_G5_G5A_POST_GRANT_INTENT!==INTENT|| + !e.DATABASE_URL||!e.V39_DATABASE_RUNTIME_URL|| + e.DATABASE_URL===e.V39_DATABASE_RUNTIME_URL|| + e.ADB_AUTO_COLLECT!=="0"||e.V39_PHASE6_PROCESS_ARMED!=="0"|| + e.GITHUB_ACTIONS==="true") + hold("OPERATOR_INTENT_OR_DB_URL"); + const commit=execFileSync("git",["rev-parse","HEAD"], + {encoding:"utf8",timeout:4000}).trim(); + if(!SHA.test(commit))hold("SOURCE_PIN"); + const admin=new pg.Client({connectionString:e.DATABASE_URL, + connectionTimeoutMillis:8500,query_timeout:12000, + application_name:"g5a_post_ledger_exact_grant_once"}); + const runtime=new pg.Client({connectionString:e.V39_DATABASE_RUNTIME_URL, + connectionTimeoutMillis:8500,query_timeout:12000, + application_name:"g5a_post_ledger_runtime_restricted_ro"}); + await admin.connect();await runtime.connect(); + try{ + const [a,w]=await Promise.all([ + admin.query(`SELECT current_database() AS db,current_user AS role, + current_setting('server_version_num')::int>=160000 AS pg16, + (SELECT rolsuper OR rolcreaterole FROM pg_roles WHERE rolname=current_user) AS can_grant`), + runtime.query(`SELECT current_database() AS db,current_user AS role, + has_table_privilege(current_user,$1,'DELETE') AS can_delete_source`,[TABLE]) + ]); + const ar=a.rows[0],wr=w.rows[0]; + if(a.rows.length!==1||w.rows.length!==1|| + ar.pg16!==true||ar.can_grant!==true|| + ar.db!==wr.db||ar.role===wr.role) + hold("ROLE_SEPARATION_OR_PG16"); + const target=await admin.query(`SELECT COUNT(*)::int n FROM pg_roles + WHERE rolname=$1 AND NOT rolsuper AND NOT rolcreatedb + AND NOT rolcreaterole AND NOT rolreplication AND NOT rolbypassrls`,[ROLE]); + if(Number(target.rows[0]?.n)!==1)hold("SOURCE_OWNER_MISSING_OR_PRIVILEGED"); + const rel=await admin.query("SELECT to_regclass($1::text) AS found",[TABLE]); + if(rel.rows.length!==1||!rel.rows[0].found)hold("BASELINE_LEDGER_NOT_PRESENT"); + const old=await admin.query(`SELECT + has_table_privilege($1,$2,'SELECT') can_select, + has_table_privilege($1,$2,'INSERT') can_insert, + has_table_privilege($1,$2,'UPDATE') can_update, + has_table_privilege($1,$2,'DELETE') can_delete, + has_table_privilege($1,$2,'TRUNCATE') can_truncate, + has_table_privilege($1,$2,'REFERENCES') can_ref, + has_table_privilege($1,$2,'TRIGGER') can_trigger`,[ROLE,TABLE]); + if(old.rows.length!==1)hold("ORIGINAL_TABLE_ACL_UNVERIFIABLE"); + const prior=old.rows[0]; + if(prior.can_update||prior.can_delete||prior.can_truncate||prior.can_ref||prior.can_trigger) + hold("UNEXPECTED_EXISTING_DANGEROUS_GRANTS"); + let changed=false; + if(!(prior.can_select&&prior.can_insert)){ + await admin.query("BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE READ WRITE"); + let tx=true; + try{ + await admin.query("SET LOCAL statement_timeout='10000ms'"); + await admin.query("SET LOCAL lock_timeout='1500ms'"); + const lock=await admin.query("SELECT pg_try_advisory_xact_lock(76,97) AS ok"); + if(lock.rows[0]?.ok!==true)hold("CONCURRENT_GRANT"); + await admin.query("GRANT SELECT,INSERT ON TABLE "+TABLE+" TO "+ROLE); + await admin.query("COMMIT");tx=false;changed=true; + }finally{if(tx)await admin.query("ROLLBACK").catch(()=>undefined)} + } + const post=await admin.query(`SELECT + has_table_privilege($1,$2,'SELECT') s, + has_table_privilege($1,$2,'INSERT') i, + has_table_privilege($1,$2,'UPDATE') u, + has_table_privilege($1,$2,'DELETE') d, + has_table_privilege($1,$2,'TRUNCATE') t, + has_table_privilege($1,$2,'REFERENCES') r, + has_table_privilege($1,$2,'TRIGGER') g`,[ROLE,TABLE]); + const x=post.rows[0]; + if(post.rows.length!==1||!x.s||!x.i||x.u||x.d||x.t||x.r||x.g) + hold("POSTCOMMIT_TABLE_ACL"); + const pub=await admin.query(`SELECT EXISTS( + SELECT 1 FROM pg_class c + JOIN pg_namespace n ON n.oid=c.relnamespace + CROSS JOIN LATERAL aclexplode( + COALESCE(c.relacl,acldefault('r',c.relowner))) p + WHERE n.nspname='clean' AND c.relname='adb_rest_attempt_ledger' + AND p.grantee=0 AND p.privilege_type='INSERT' + ) AS public_insert`); + if(pub.rows[0]?.public_insert===true)hold("PUBLIC_INSERT_ALREADY_ALLOWED"); + console.log(JSON.stringify({ + status:changed? + "G5A_POST_BASELINE_LEDGER_EXACT_SELECT_INSERT_GRANTED_ONCE": + "G5A_POST_BASELINE_LEDGER_GRANT_ALREADY_PRESENT_NO_REINSTALL", + sourceRoleNonAdmin:true,sourceRoleNoDelete:true, + sourceRoleNoSchemaDdlGranted:true, + publicInsertNotEnabled:true,providerCalls:0, + databaseContentWrites:0,phase5FreezeAuthorized:false + })); + }finally{ + await runtime.end().catch(()=>undefined); + await admin.end().catch(()=>undefined); + } +} +if(process.argv[1]?.endsWith("v39_phase4_gate5_g5a_post_ledger_exact_grant_once.ts")) + void main().catch(e=>{ + const msg=e instanceof Error?e.message:""; + console.error(/^P4_G5_G5A_POST_GRANT_[A-Z0-9_]+_HOLD$/.test(msg)? + msg:"P4_G5_G5A_POST_GRANT_UNKNOWN_NO_SECRETS_HOLD"); + process.exitCode=2; + }); diff --git a/scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts b/scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts new file mode 100644 index 000000000..4d2146a18 --- /dev/null +++ b/scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts @@ -0,0 +1,363 @@ +/** + * G5A one-time, Github-only, prospective WSSS POST source observation. + * Three immutable budget-ledger reservations (max 6 provider REST units). + * + * First original T is the per-flight provider-reported scheduled departure. + * Prediction label: schedule revision vs actual as-of PRE by W=T+2h; + * NOT actual wheels-off. No downstream models are trained here. + * + * 3 fixed POST GETs at 03:40, 04:10, 04:40Z 2026-11-05, within a fixed + * WSSS 01:00-05:00Z local source range (09:00-13:00 Singapore time). + * A missing/lost identity/schedule is UNKNOWN, not a negative/actual. + * + * This module does not run for PR, push, workflow_dispatch or in Replit. + * It requires verified real GitHub schedule metadata and preexisting role + * with SELECT/INSERT on clean.adb_rest_attempt_ledger. + */ +import {createHash} from "node:crypto"; +import {execFileSync} from "node:child_process"; +import {readFileSync} from "node:fs"; +import pg from "pg"; +import {fetchFidsPopulation,type FidsCensusParams,type FidsQueryObservation, + type PopulationRow,persistFidsObservation} + from "../server/lib/disruption/fidsCensus_v3"; +import {fetchFidsAirport,getBalanceEvidenceStrict} + from "../server/lib/disruption/aerodataboxLimiter_v3"; +const REPO="HKcode22/ReplitTranvr"; +const WF="Phase4 Gate5 G5A WSSS Real Prospective POST"; +const PATH=".github/workflows/phase4-gate5-g5a-wsss-real-post.yml"; +const CRON="10 2 5 11 *"; +const MANIFEST="data-collection/phase4/GATE5_G5A_WSSS_20261105_FROZEN_SOURCE_GRID.json"; +const FROM="2026-11-05T01:00:00.000Z",TO="2026-11-05T05:00:00.000Z"; +const POSTS=["2026-11-05T03:40:00.000Z","2026-11-05T04:10:00.000Z", + "2026-11-05T04:40:00.000Z"] as const; +const FINAL="2026-11-05T05:01:00.000Z"; +const END="2026-11-05T06:00:00.000Z"; +const SHA=/^[a-f0-9]{64}$/; +const GH=/^[a-f0-9]{40}$/; +const ms=(s:string)=>Date.parse(s); +const hash=(s:string)=>createHash("sha256").update(s).digest("hex"); +const sleep=(m:number)=>new Promise(r=>setTimeout(r,m)); +function hold(code:string):never{throw Error("P4_G5_G5A_POST_"+code+"_HOLD")} +function safe(e:unknown){ + const s=e instanceof Error?e.message:""; + return /^P4_G5_G5A_POST_[A-Z0-9_]+_HOLD$/.test(s)?s: + "P4_G5_G5A_POST_INTERNAL_FAILURE_NO_SOURCE_PRINTED_HOLD"; +} +function object(x:unknown):Record{ + if(!x||typeof x!=="object"||Array.isArray(x))hold("BAD_PROOF_JSON"); + return x as Record; +} +export function postReservationUuidV39(manifestSha:string,slot:number){ + if(!SHA.test(manifestSha)||!Number.isInteger(slot)||slot<0||slot>2) + hold("RESERVATION_DIGEST"); + const b=Buffer.from(hash("G5A-POST|"+manifestSha+"|"+slot).slice(0,32),"hex"); + b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128; + const h=b.toString("hex"); + return h.slice(0,8)+"-"+h.slice(8,12)+"-"+h.slice(12,16)+"-"+ + h.slice(16,20)+"-"+h.slice(20,32); +} +function manifest(){ + const bytes=readFileSync(MANIFEST,"utf8"); + let doc:unknown;try{doc=JSON.parse(bytes)}catch{hold("FROZEN_JSON")} + const d=object(doc),g=object(d.grid); + if(bytes!==JSON.stringify(doc,null,2)+"\n"|| + d.cohortId!=="WSSS_G5A_20261105_0200Z"|| + g.airportIcao!=="WSSS"|| + g.serviceWindowStartUtc!=="2026-11-05T02:00:00.000Z"|| + g.serviceWindowEndUtc!=="2026-11-05T03:00:00.000Z"|| + g.projectRestApiUnitsBudget!==24|| + g.maxSourceQueries!==9||g.maxAttemptsPerQuery!==1) + hold("FROZEN_GRID_CHANGED"); + return {sha:hash(bytes),cohortId:String(d.cohortId)}; +} +export function assertG5APostRunnerContextV39(e:NodeJS.ProcessEnv,commit:string,at:number){ + if(!GH.test(commit)||!Number.isFinite(at)|| + e.GITHUB_ACTIONS!=="true"||e.GITHUB_REPOSITORY!==REPO|| + e.GITHUB_REF!=="refs/heads/main"||e.GITHUB_SHA!==commit|| + e.GITHUB_EVENT_NAME!=="schedule"||e.GITHUB_RUN_ATTEMPT!=="1"|| + e.GITHUB_JOB!=="post-source"||e.GITHUB_WORKFLOW!==WF|| + e.GITHUB_WORKFLOW_REF!==REPO+"/"+PATH+"@refs/heads/main"|| + e.G5A_POST_SCHEDULE!==CRON|| + e.G5A_POST_PAID_INTENT!=="G5A_NOV05_POST_THREE_2_UNIT_REQUESTS_MAX"|| + !/^\d{5,25}$/.test(e.GITHUB_RUN_ID??"")|| + !e.GITHUB_TOKEN||e.GITHUB_TOKEN.length<10|| + !e.AERODATABOX_API_KEY||e.AERODATABOX_API_KEY.length<10|| + !e.P4_G5_SOURCE_OWNER_DATABASE_URL|| + e.V39_DATABASE_RUNTIME_URL!==e.P4_G5_SOURCE_OWNER_DATABASE_URL|| + !!e.DATABASE_URL||!!e.GATE05_AUTOMATION_DATABASE_URL|| + e.ADB_AUTO_COLLECT!=="0"||e.V39_PHASE6_PROCESS_ARMED!=="0"|| + at=ms(END)) + hold("GITHUB_CONTEXT_OR_SECRETS"); +} +async function independentlyVerifyGithubRun(e:NodeJS.ProcessEnv,commit:string){ + const get=async(tail:string)=>{ + const res=await fetch("https://api.github.com/repos/"+REPO+tail,{ + method:"GET",redirect:"error",signal:AbortSignal.timeout(8500), + headers:{Authorization:"Bearer "+e.GITHUB_TOKEN, + "X-GitHub-Api-Version":"2022-11-28", + Accept:"application/vnd.github+json", + "User-Agent":"gate5-g5a-post-source"} + }); + if(!res.ok)hold("LIVE_GITHUB_RUN_NOT_VERIFIED"); + return object(await res.json()); + }; + const id=e.GITHUB_RUN_ID!; + const [run,jobs]=await Promise.all([ + get("/actions/runs/"+id),get("/actions/runs/"+id+"/jobs?per_page=100") + ]); + if(!Array.isArray(jobs.jobs))hold("GH_JOB_COUNT"); + const found=jobs.jobs.map(object).filter(x=>x.name==="post-source"); + if(found.length!==1)hold("GH_POST_JOB_MISSING"); + const job=found[0],start=ms(String(job.started_at)),current=Date.now(); + if(run.id!==Number(id)||run.head_sha!==commit|| + run.event!=="schedule"||run.name!==WF||run.path!==PATH|| + run.head_branch!=="main"||run.run_attempt!==1|| + run.status!=="in_progress"||run.conclusion!==null|| + object(run.repository).full_name!==REPO|| + job.run_id!==Number(id)||job.status!=="in_progress"|| + job.conclusion!==null|| + !Number.isFinite(start)||start>current|| + start>ms("2026-11-05T03:25:00.000Z")|| + current>ms("2026-11-05T03:25:00.000Z")|| + current-start>90*60000) + hold("GH_POST_NOT_ACTUALLY_PREARMED"); + return {githubRunId:id,realStartedAt: new Date(start).toISOString()}; +} +async function waitUntil(at:number){ + while(Date.now()24) + hold("PROJECT_24_UNIT_BUDGET"); + const prior=await db.query( + "SELECT reservation_id FROM clean.adb_rest_attempt_ledger "+ + "WHERE cycle_id=$1 AND category=$2",[cycle,category]); + // Earlier POST slots may be genuinely UNOBSERVED (missed physical + // deadline or missing balance). Accept only previously reserved *earlier* + // fixed slots, never a duplicate, future or foreign reservation. + const validPrior=POSTS.slice(0,slot).map((_,n)=> + postReservationUuidV39(manifestSha,n)); + if(prior.rows.length>slot||prior.rows.some(x=> + !validPrior.includes(String(x.reservation_id)))|| + new Set(prior.rows.map(x=>String(x.reservation_id))).size!==prior.rows.length) + hold("POST_RESERVATION_REPLAY_OR_FOREIGN_SLOT"); + const changed=await db.query( + "UPDATE clean.phase4_g5_fids_cohort_budget_v39 "+ + "SET reserved_units=reserved_units+2 "+ + "WHERE manifest_sha256=$1 AND reserved_units+2<=project_unit_ceiling "+ + "RETURNING reserved_units", + [manifestSha]); + if(changed.rows.length!==1|| + !Number.isSafeInteger(Number(changed.rows[0].reserved_units))) + hold("POST_BUDGET_RACE_OR_UPDATE_DRIFT"); + const result=await db.query( + "INSERT INTO clean.adb_rest_attempt_ledger "+ + "(reservation_id,cycle_id,category,units,attempt_number,reserved_at) "+ + "VALUES ($1,$2,$3,2,1,clock_timestamp()) RETURNING reservation_id", + [id,cycle,category]); + if(result.rows.length!==1||result.rows[0].reservation_id!==id) + hold("POST_LEDGER_NOT_DURABLE"); + await db.query("COMMIT");tx=false; + return id; + }catch(e){ + if(tx)await db.query("ROLLBACK").catch(()=>undefined); + throw e; + } +} +export const G5A_POST_AGGREGATE_AUDIT_SQL_V39=` +SELECT pre.horizon, + count(*)::int AS expected_pre, + count(baseline.dep_scheduled_utc)::int AS baseline_sourced, + count(post.dep_scheduled_utc)::int AS post_observed, + count(*) FILTER(WHERE baseline.dep_scheduled_utc IS NOT NULL + AND post.dep_scheduled_utc IS NOT NULL + AND abs(extract(epoch from + (post.dep_scheduled_utc-baseline.dep_scheduled_utc))/60)>=15)::int AS revision_ge15, + count(*) FILTER(WHERE baseline.dep_scheduled_utc IS NOT NULL + AND post.dep_scheduled_utc IS NOT NULL + AND abs(extract(epoch from + (post.dep_scheduled_utc-baseline.dep_scheduled_utc))/60)<15)::int AS revision_lt15 +FROM ( + SELECT flight_instance_id,horizon,prediction_cutoff_utc, + selected_t_milestone_utc,population_query_id + FROM clean.flight_snapshots + WHERE frame_hash=$1 AND prediction_state='PRE_DEPARTURE' + AND selected_t_milestone_utc IS NOT NULL + AND flight_instance_id LIKE 'leg:g5a:%' +) pre +LEFT JOIN LATERAL( + SELECT p.dep_scheduled_utc + FROM clean.flight_population p + WHERE p.analytic_identity_id=pre.flight_instance_id + AND p.population_query_id::text=pre.population_query_id + AND p.fids_protocol_version='G5A-20261103-v1' + AND p.available_at<=pre.prediction_cutoff_utc + AND p.dep_scheduled_utc IS NOT NULL + ORDER BY p.available_at DESC,p.id DESC LIMIT 1 +) baseline ON TRUE +LEFT JOIN LATERAL( + SELECT p.dep_scheduled_utc + FROM clean.flight_population p + JOIN clean.fids_query_response q + ON q.population_query_id=p.population_query_id + WHERE p.analytic_identity_id=pre.flight_instance_id + AND p.fids_protocol_version='G5A-POST-20261103-v1' + AND p.batch_id=ANY($2::text[]) + AND q.raw_payload->>'g5aPostReservationId'=p.batch_id + AND p.available_at>pre.prediction_cutoff_utc + AND p.available_at<=pre.selected_t_milestone_utc+INTERVAL '2 hours' + AND p.dep_scheduled_utc IS NOT NULL + AND p.population_role='requested_airport_primary' + AND p.scope_classification='confirmed_core' + AND p.codeshare_resolution_status='confirmed_operating_leg' + ORDER BY p.available_at DESC,p.id DESC LIMIT 1 +) post ON TRUE +GROUP BY pre.horizon ORDER BY pre.horizon`; +async function readAgg(db:pg.Client,manifestSha:string,ids:string[]){ + let tx=false; + try{ + await db.query("BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY");tx=true; + const r=await db.query(G5A_POST_AGGREGATE_AUDIT_SQL_V39,[manifestSha,ids]); + await db.query("ROLLBACK");tx=false; + return r.rows.map(x=>{ + const expected=Number(x.expected_pre),base=Number(x.baseline_sourced); + const observed=Number(x.post_observed); + const positive=Number(x.revision_ge15),negative=Number(x.revision_lt15); + if(![expected,base,observed,positive,negative].every(n=> + Number.isSafeInteger(n)&&n>=0)||positive+negative>observed|| + observed>expected||base>expected) + hold("AGGREGATE_NUMERATOR_DENOMINATOR_INVALID"); + return { + horizon:String(x.horizon),persistedPRE:expected,asofBaseline:base, + observedPOST:observed, + observedRevisionGE15Minutes:positive, + observedRevisionLT15Minutes:negative, + unknownNoQualifiedPOSTOrPREBaseline:expected-positive-negative, + actualWheelsOffClaim:false + }; + }); + }finally{if(tx)await db.query("ROLLBACK").catch(()=>undefined)} +} +export async function main(){ + const e=process.env,head=execFileSync("git",["rev-parse","HEAD"], + {encoding:"utf8",timeout:4000}).trim(); + assertG5APostRunnerContextV39(e,head,Date.now()); + const {sha:manifestSha,cohortId}=manifest(); + const proof=await independentlyVerifyGithubRun(e,head); + const db=new pg.Client({connectionString:e.P4_G5_SOURCE_OWNER_DATABASE_URL, + connectionTimeoutMillis:9000,query_timeout:15000, + application_name:"g5a_real_post_source_reservation"}); + await db.connect(); + const ids=POSTS.map((_,i)=>postReservationUuidV39(manifestSha,i)); + let charged=0,originals=0,unknown=0,normalized=0; + try{ + for(const [i,planned] of POSTS.entries()){ + await waitUntil(ms(planned)); + if(Date.now()>ms(planned)+10*60000){unknown++;continue} + let quota:{apiUnitsRemaining:number;requestsRemaining:number; + observedAtUtc:string}; + try{quota=(await getBalanceEvidenceStrict()).quota} + catch{unknown++;continue} + if(Date.now()-ms(quota.observedAtUtc)>30000|| + quota.apiUnitsRemaining<2||quota.requestsRemaining<2){ + unknown++;continue; + } + const id=await reserveG5APostSlotDbV39(db,manifestSha,cohortId,i); + charged++; + let callback=0; + const q:FidsCensusParams={ + airportIcao:"WSSS",fromLocal:"2026-11-05T09:00", + toLocal:"2026-11-05T13:00",ianaTimezone:"Asia/Singapore", + serviceWindowStartUtc:new Date(FROM), + serviceWindowEndUtc:new Date(TO),cutoffUtc:new Date(planned), + withCancelled:true,withCodeshared:true,withCargo:false,withPrivate:false, + direction:"Departure",restCategory:"outcome", + // The Plan f.8 §5.1 verified OpenAPI DOC BYTES pin is v1.15.3.0. + // A G5A protocol text SHA is NOT an AeroDataBox OpenAPI document SHA. + // Do not conflate frozen docs or live server schema version. + providerApiVersion:"RapidAPI-doc-pin-1.15.3.0-not-live-schema-certified", + fidsProtocolVersion:"G5A-POST-20261103-v1", + openapiSha256:"735620f2d2132c5bf51768f50caa767b7f0b25be8b128679641402666696890a", + g5aObservedScheduleProtocol:true,g5aPostReservationId:id,batchId:id + }; + try{ + const res=await fetchFidsPopulation(q,{ + fetchAirport:async(icao,from,to,opts)=>fetchFidsAirport(icao,from,to,{ + ...opts,budgetOwner:{async reserve(_category,units,attempt){ + if(callback!==0||units!==2||attempt!==1) + throw Error("G5A_POST_REPEAT_PAID_GET_FORBIDDEN"); + callback++;return id; + }} + }), + persist:async(query:FidsQueryObservation,rows:PopulationRow[])=>{ + const raw=query.rawPayload as Record; + if(raw?.g5aPostReservationId!==id|| + raw?.originalDecodedEntityBodyPreserved!==true) + hold("POST_SOURCE_ORIGINAL_NOT_AUTHENTICATED"); + return persistFidsObservation(query,rows); + } + }); + if(!res||callback!==1){unknown++;continue} + originals++;normalized+=res.persistedPopulationRows; + console.log(JSON.stringify({ + status:"G5A_POST_ORIGINAL_SOURCE_PERSISTED", + slot:i,reservationCommittedBeforeGET:true, + populationRows:res.persistedPopulationRows, + sourceQueryIdPrinted:false,sourceContentPrinted:false, + providerUnitsReservedCumulative:charged*2, + actualDepartureDelayClaimed:false + })); + }catch(ex){ + unknown++; + console.error(JSON.stringify({ + status:"G5A_POST_RESERVED_SLOT_OUTCOME_UNKNOWN", + slot:i,error:safe(ex),noRepeatedPaidGET:true + })); + } + } + await waitUntil(ms(FINAL)); + const agg=await readAgg(db,manifestSha,ids); + console.log(JSON.stringify({ + status:"G5A_POST_FROZEN_WATERMARK_AGGREGATE_OBSERVED_NOT_GATE5_VERDICT", + mainSha:head,githubRunId:proof.githubRunId, + verifiedJobStartedAtUtc:proof.realStartedAt, + frozenManifestSha256:manifestSha, + postSlotsPlanned:3,paidReservations:charged, + providerRestUnitsReserved:charged*2, + originalPostReceipts:originals,normalizedPostRows:normalized, + unknownSourceSlots:unknown,watermarkRule:"T_NATIVE_SCHEDULE_PLUS_2H", + outcomeKind:"PROVIDER_REPORTED_SCHEDULE_REVISION_NOT_ACTUAL_DEPARTURE", + labelCountsByHorizon:agg, + missingPostObservationsAreNotNegative:true, + independentReadOnlyAuditStillRequired:true, + strictGate5Passed:false,phase5FreezeAuthorized:false, + phase6Authorized:false + })); + }finally{await db.end().catch(()=>undefined)} +} +if(process.argv[1]?.endsWith("v39_phase4_gate5_g5a_prospective_post_schedule_observation.ts")) + void main().catch(e=>{ + console.error(JSON.stringify({status:"G5A_GITHUB_POST_OWNER_HOLD", + reason:safe(e),providerContentPrinted:false, + strictGate5Passed:false,phase6Authorized:false})); + process.exitCode=2; + }); diff --git a/server/lib/disruption/fidsCensus_v3.ts b/server/lib/disruption/fidsCensus_v3.ts index 4800231e7..c58261e46 100644 --- a/server/lib/disruption/fidsCensus_v3.ts +++ b/server/lib/disruption/fidsCensus_v3.ts @@ -37,6 +37,8 @@ export interface FidsCensusParams { openapiSha256: string; /** Only prospective G5A: stable provider operating identity under retimes. */ g5aObservedScheduleProtocol?: boolean; + /** Separately ledger-reserved G5A POST source; never a PRE identifier. */ + g5aPostReservationId?:string; } /** @@ -447,9 +449,11 @@ function normalizeFidsRecord(input: { const stable=createHash("sha256").update(JSON.stringify({ protocol:"G5A_FIRST_NATIVE_SCHEDULE_REVISION", providerId:sourceId,carrier:parsed.carrier,flightNumber:parsed.number, - origin:depIcao,destination:arrIcao, - frozenServiceStartUtc:params.serviceWindowStartUtc.toISOString(), - frozenServiceEndUtc:params.serviceWindowEndUtc.toISOString() + // A postflight FIDS query may cover a wider source window because + // the operating flight was retimed out of the original hour. Source + // window is query metadata, NOT physical identity. Positive provider + // flight ID plus carrier/route is the stable cross-window link. + origin:depIcao,destination:arrIcao })).digest("hex"); canonical="leg:g5a:"+stable; analyticIdentityId=canonical; @@ -583,6 +587,11 @@ export async function fetchFidsPopulation( bodyBase64: original.base64, }; } + if(params.g5aPostReservationId!==undefined&& + (params.g5aObservedScheduleProtocol!==true|| + params.fidsProtocolVersion!=="G5A-POST-20261103-v1"|| + !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(params.g5aPostReservationId))) + throw Error("G5A_POST_RESERVATION_SCOPE_INVALID_HOLD"); const retrievalUtc = (deps.now ?? (() => new Date()))(); const rawPayload = addCanonicalFidsEnvelopeHashV39({ airport: params.airportIcao, @@ -591,6 +600,10 @@ export async function fetchFidsPopulation( ianaTimezone: params.ianaTimezone, // Hash-bound application request intent, NOT original provider HTTP bytes. fidsRequestContract:frame, + ...(params.g5aPostReservationId?{ + g5aPostReservationId:params.g5aPostReservationId, + g5aPostPurpose:"PROVIDER_REPORTED_SCHEDULE_REVISION_NOT_ACTUAL_DELAY" + }:{}), g5aProtocol:params.g5aObservedScheduleProtocol===true? "G5A_FIRST_PROVIDER_REPORTED_SCHEDULE_REVISION":null, payloadRepresentation:"RECONSTRUCTED_RESPONSE_JSON_NOT_VERBATIM_HTTP_BYTES", diff --git a/tests/phase4_gate5_g5a_native_fids_identity_v39.test.ts b/tests/phase4_gate5_g5a_native_fids_identity_v39.test.ts index 933dbbae9..6af8567d2 100644 --- a/tests/phase4_gate5_g5a_native_fids_identity_v39.test.ts +++ b/tests/phase4_gate5_g5a_native_fids_identity_v39.test.ts @@ -49,6 +49,22 @@ describe("G5A provider FIDS native identity, prospectively enabled only",()=>{ expect(first.query.rawPayload).toHaveProperty("g5aProtocol", "G5A_FIRST_PROVIDER_REPORTED_SCHEDULE_REVISION"); }); + it("a real late retime into a wider postflight source window retains one physical leg",async()=>{ + const original=await one(base(),leg("2026-11-05T02:20:00.000Z", + "G5A-OPERATING-LEG-121")); + const widened=base(); + widened.fromLocal="2026-11-05T09:00"; + widened.toLocal="2026-11-05T13:00"; + widened.serviceWindowStartUtc=new Date("2026-11-05T01:00:00.000Z"); + widened.serviceWindowEndUtc=new Date("2026-11-05T05:00:00.000Z"); + const after=await one(widened,leg("2026-11-05T03:40:00.000Z", + "G5A-OPERATING-LEG-121")); + expect(after.row.canonicalFlightInstanceId) + .toBe(original.row.canonicalFlightInstanceId); + expect(after.row.analyticIdentityId).toBe(original.row.analyticIdentityId); + expect(after.row.depScheduledUtc!.toISOString()) + .not.toBe(original.row.depScheduledUtc!.toISOString()); + }); it("different provider flight ID and destination must never silently merge",async()=>{ const a=await one(base(),leg("2026-11-05T02:20:00.000Z","P-1")); const b=await one(base(),leg("2026-11-05T02:20:00.000Z","P-2")); diff --git a/tests/phase4_gate5_g5a_post_ledger_pg16.test.ts b/tests/phase4_gate5_g5a_post_ledger_pg16.test.ts new file mode 100644 index 000000000..5e31b927b --- /dev/null +++ b/tests/phase4_gate5_g5a_post_ledger_pg16.test.ts @@ -0,0 +1,60 @@ +import {describe,it,expect} from "vitest"; +import pg from "pg"; +import {postReservationUuidV39,reserveG5APostSlotDbV39} + from "../scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation"; + +const suite=process.env.G5A_POST_TEST_PG16==="1"?describe:describe.skip; +const M="a".repeat(64), N="b".repeat(64), K="c".repeat(64); +const url=process.env.G5A_POST_TEST_WRITER_URL??""; +const qid="WSSS_G5A_20261105_0200Z"; +suite("actual isolated PG16 immutable G5A POST budget, rights, replay",()=>{ + it("reserves only 3 unique 2-unit POST attempts after 18 source units",async()=>{ + const db=new pg.Client({connectionString:url}); + await db.connect(); + try{ + // The disposable test setup pre-inserts three cohort budgets as + // privileged schema bootstrap; this writer must NOT have INSERT on + // the budget table merely to run a postflight observation. + const id0=await reserveG5APostSlotDbV39(db,M,qid,0); + const id1=await reserveG5APostSlotDbV39(db,M,qid,1); + const id2=await reserveG5APostSlotDbV39(db,M,qid,2); + expect([id0,id1,id2]).toEqual([0,1,2].map(i=>postReservationUuidV39(M,i))); + const result=await db.query( + "SELECT reserved_units FROM clean.phase4_g5_fids_cohort_budget_v39 "+ + "WHERE manifest_sha256=$1",[M]); + expect(Number(result.rows[0].reserved_units)).toBe(24); + const ledger=await db.query( + "SELECT count(*)::int n,sum(units)::int spent FROM clean.adb_rest_attempt_ledger "+ + "WHERE cycle_id=$1",["G5A_NOV05_"+M.slice(0,32)]); + expect(ledger.rows[0].n).toBe(3); + expect(ledger.rows[0].spent).toBe(6); + await expect(reserveG5APostSlotDbV39(db,M,qid,0)) + .rejects.toThrow("P4_G5_G5A_POST_PROJECT_24_UNIT_BUDGET_HOLD"); + // Missing earlier POST attempts are genuinely unknown, not a reason to + // prohibit the later independent source observation opportunity. + const last=await reserveG5APostSlotDbV39(db,N,qid+'_N',2); + expect(last).toBe(postReservationUuidV39(N,2)); + const gap=await db.query( + "SELECT reserved_units FROM clean.phase4_g5_fids_cohort_budget_v39 "+ + "WHERE manifest_sha256=$1",[N]); + expect(Number(gap.rows[0].reserved_units)).toBe(20); + await expect(reserveG5APostSlotDbV39(db,N,qid+'_N',1)) + .rejects.toThrow("P4_G5_G5A_POST_POST_RESERVATION_REPLAY_OR_FOREIGN_SLOT_HOLD"); + await expect(reserveG5APostSlotDbV39(db,K,qid+'_K',0)) + .rejects.toThrow("P4_G5_G5A_POST_PROJECT_24_UNIT_BUDGET_HOLD"); + const all=await db.query( + "SELECT count(*)::int n FROM clean.adb_rest_attempt_ledger "+ + "WHERE category='g5a_post_schedule_fids'"); + expect(all.rows[0].n).toBe(4); + const perms=await db.query(`SELECT + has_table_privilege(current_user,'clean.adb_rest_attempt_ledger','INSERT') i, + has_table_privilege(current_user,'clean.adb_rest_attempt_ledger','SELECT') s, + has_table_privilege(current_user,'clean.adb_rest_attempt_ledger','UPDATE') u, + has_table_privilege(current_user,'clean.adb_rest_attempt_ledger','DELETE') d, + has_schema_privilege(current_user,'clean','CREATE') c`); + expect(perms.rows[0]).toMatchObject({ + i:true,s:true,u:false,d:false,c:false + }); + }finally{await db.end().catch(()=>undefined)} + }); +}); diff --git a/tests/phase4_gate5_g5a_post_source_v39.test.ts b/tests/phase4_gate5_g5a_post_source_v39.test.ts new file mode 100644 index 000000000..9163839dc --- /dev/null +++ b/tests/phase4_gate5_g5a_post_source_v39.test.ts @@ -0,0 +1,132 @@ +import {describe,it,expect} from "vitest"; +import {fetchFidsPopulation,type FidsCensusParams, + type PopulationRow,type FidsQueryObservation} + from "../server/lib/disruption/fidsCensus_v3"; +import { + postReservationUuidV39,assertG5APostRunnerContextV39, + G5A_POST_AGGREGATE_AUDIT_SQL_V39 +} from "../scripts/v39_phase4_gate5_g5a_prospective_post_schedule_observation"; + +const sha="a".repeat(64); +const git="b".repeat(40); +const id=postReservationUuidV39(sha,0); +const base=():FidsCensusParams=>({ + airportIcao:"WSSS",fromLocal:"2026-11-05T10:00", + toLocal:"2026-11-05T11:00",ianaTimezone:"Asia/Singapore", + serviceWindowStartUtc:new Date("2026-11-05T02:00:00.000Z"), + serviceWindowEndUtc:new Date("2026-11-05T03:00:00.000Z"), + cutoffUtc:new Date("2026-11-04T02:00:00.000Z"), + withCancelled:true,withCodeshared:true,withCargo:false,withPrivate:false, + direction:"Departure",restCategory:"fids_base", + providerApiVersion:"RapidAPI-v1.15.4",fidsProtocolVersion:"G5A-20261103-v1", + openapiSha256:sha,g5aObservedScheduleProtocol:true +}); +const flight=(time:string)=>({ + id:"G5A-OPERATING-9381",number:"SQ 115",codeshareStatus:"IsOperator", + departure:{scheduledTime:{utc:time}}, + arrival:{airport:{icao:"YSSY"},scheduledTime:{utc:"2026-11-05T08:00:00.000Z"}} +}); +async function readOne(p:FidsCensusParams,utc:string){ + let rows:PopulationRow[]=[];let query:FidsQueryObservation|null=null; + const result=await fetchFidsPopulation(p,{ + fetchAirport:async()=>({departures:[flight(utc)],arrivals:[]}), + persist:async(q,rr)=>{query=q;rows=rr;return rr.length}, + now:()=>new Date("2026-11-04T01:30:00.000Z") + }); + expect(result?.persistedPopulationRows).toBe(1); + return {result:result!,query:query!,row:rows[0]}; +} +const env=()=>({ + GITHUB_ACTIONS:"true",GITHUB_REPOSITORY:"HKcode22/ReplitTranvr", + GITHUB_REF:"refs/heads/main",GITHUB_SHA:git, + GITHUB_EVENT_NAME:"schedule",GITHUB_RUN_ATTEMPT:"1", + GITHUB_RUN_ID:"41600012345",GITHUB_JOB:"post-source", + GITHUB_WORKFLOW:"Phase4 Gate5 G5A WSSS Real Prospective POST", + GITHUB_WORKFLOW_REF:"HKcode22/ReplitTranvr/.github/workflows/phase4-gate5-g5a-wsss-real-post.yml@refs/heads/main", + G5A_POST_SCHEDULE:"10 2 5 11 *", + G5A_POST_PAID_INTENT:"G5A_NOV05_POST_THREE_2_UNIT_REQUESTS_MAX", + GITHUB_TOKEN:"test-not-real-github-token", + AERODATABOX_API_KEY:"test-not-real-provider-key", + P4_G5_SOURCE_OWNER_DATABASE_URL:"postgresql://writer:fake@localhost/test", + V39_DATABASE_RUNTIME_URL:"postgresql://writer:fake@localhost/test", + ADB_AUTO_COLLECT:"0",V39_PHASE6_PROCESS_ARMED:"0" +}); +describe("G5A prospective POST provider source, time and budget admission",()=>{ + it("makes deterministic, distinct UUID v4 slots from exact manifest",()=>{ + expect(id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + expect(postReservationUuidV39(sha,0)).toBe(id); + expect(new Set([0,1,2].map(i=>postReservationUuidV39(sha,i))).size).toBe(3); + expect(()=>postReservationUuidV39("fake",0)).toThrow("P4_G5_G5A_POST_RESERVATION_DIGEST_HOLD"); + expect(()=>postReservationUuidV39(sha,3)).toThrow("P4_G5_G5A_POST_RESERVATION_DIGEST_HOLD"); + }); + it("retimed physical flight has same positive identity across PRE/POST query windows",async()=>{ + const before=await readOne(base(),"2026-11-05T02:20:00.000Z"); + const later=base(); + later.serviceWindowStartUtc=new Date("2026-11-05T01:00:00.000Z"); + later.serviceWindowEndUtc=new Date("2026-11-05T05:00:00.000Z"); + later.fromLocal="2026-11-05T09:00"; + later.toLocal="2026-11-05T13:00"; + later.fidsProtocolVersion="G5A-POST-20261103-v1"; + later.g5aPostReservationId=id; + later.batchId=id; + const post=await readOne(later,"2026-11-05T02:40:00.000Z"); + expect(before.row.analyticIdentityId).toBe(post.row.analyticIdentityId); + expect(before.row.canonicalFlightInstanceId).toBe(post.row.canonicalFlightInstanceId); + expect(before.row.analyticIdentityId).toMatch(/^leg:g5a:[a-f0-9]{64}$/); + expect(post.row.batchId).toBe(id); + const envelope=post.query.rawPayload as Record; + expect(envelope.g5aPostReservationId).toBe(id); + expect(envelope.g5aPostPurpose) + .toBe("PROVIDER_REPORTED_SCHEDULE_REVISION_NOT_ACTUAL_DELAY"); + expect(post.query.responseHash).toMatch(/^[a-f0-9]{64}$/); + expect(JSON.stringify(before.query.rawPayload)).not.toContain(id); + }); + it("does not permit treating an arbitrary source or PRE observation as POST",async()=>{ + const p=base();p.g5aPostReservationId=id; + await expect(readOne(p,"2026-11-05T02:20:00.000Z")) + .rejects.toThrow("G5A_POST_RESERVATION_SCOPE_INVALID_HOLD"); + p.fidsProtocolVersion="G5A-POST-20261103-v1"; + p.g5aObservedScheduleProtocol=false; + await expect(readOne(p,"2026-11-05T02:20:00.000Z")) + .rejects.toThrow("G5A_POST_RESERVATION_SCOPE_INVALID_HOLD"); + }); + it("refuses PR/push/fork/retry, unknown owner role, date replay",()=>{ + const at=Date.parse("2026-11-05T02:12:00.000Z"); + expect(()=>assertG5APostRunnerContextV39(env(),git,at)).not.toThrow(); + for(const edit of [ + {GITHUB_EVENT_NAME:"workflow_dispatch"}, + {GITHUB_EVENT_NAME:"push"}, + {GITHUB_REPOSITORY:"someone/fork"}, + {GITHUB_RUN_ATTEMPT:"2"}, + {GITHUB_SHA:"c".repeat(40)}, + {GITHUB_JOB:"offline-post"}, + {G5A_POST_SCHEDULE:"bad"}, + {G5A_POST_PAID_INTENT:"UNLIMITED"}, + {GITHUB_TOKEN:""}, + {AERODATABOX_API_KEY:""}, + {V39_DATABASE_RUNTIME_URL:"postgresql://admin:fake@localhost/test"}, + {DATABASE_URL:"postgresql://admin:fake@localhost/test"}, + {GATE05_AUTOMATION_DATABASE_URL:"audit-secret-should-not-be-here"} + ]){ + expect(()=>assertG5APostRunnerContextV39({...env(),...edit},git,at)) + .toThrow("P4_G5_G5A_POST_GITHUB_CONTEXT_OR_SECRETS_HOLD"); + } + expect(()=>assertG5APostRunnerContextV39(env(),git, + Date.parse("2027-11-05T02:12:00.000Z"))) + .toThrow("P4_G5_G5A_POST_GITHUB_CONTEXT_OR_SECRETS_HOLD"); + }); + it("calculates observed only with source before W=T+2h and true PRE baseline",()=>{ + const q=G5A_POST_AGGREGATE_AUDIT_SQL_V39; + for(const must of [ + "frame_hash=$1","p.population_query_id::text=pre.population_query_id", + "p.available_at<=pre.prediction_cutoff_utc", + "p.fids_protocol_version='G5A-POST-20261103-v1'", + "q.raw_payload->>'g5aPostReservationId'=p.batch_id", + "p.batch_id=ANY($2::text[])", + "p.available_at<=pre.selected_t_milestone_utc+INTERVAL '2 hours'", + "p.available_at>pre.prediction_cutoff_utc", + "GROUP BY pre.horizon",">=15","<15" + ])expect(q).toContain(must); + expect(q).not.toContain("actual_wheels_off"); + }); +});