diff --git a/.github/workflows/apply-supabase-profile-migrations.yml b/.github/workflows/apply-supabase-profile-migrations.yml index a63a15a9..db86d9ea 100644 --- a/.github/workflows/apply-supabase-profile-migrations.yml +++ b/.github/workflows/apply-supabase-profile-migrations.yml @@ -39,11 +39,15 @@ jobs: RSYNC_SSH="ssh $SSH_OPTIONS" ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -m 700 -d '$DEPLOY_PATH/tmp/profile-migrations'" rsync -az -e "$RSYNC_SSH" \ - frontend/supabase/migrations/20260718010000_recover_missing_profile_rows.sql \ - frontend/supabase/migrations/20260718020000_profiles_service_role_upsert_grants.sql \ frontend/supabase/migrations/20260718050000_profiles_service_role_upsert_grants.sql \ + frontend/supabase/migrations/20260718060000_profiles_service_role_least_privilege.sql \ frontend/supabase/migrations/20260718070000_profiles_service_role_upsert_id.sql \ frontend/supabase/migrations/20260718080000_profiles_service_role_account_upsert_selects.sql \ + frontend/supabase/migrations/20260718100000_repair_missing_chart_profiles.sql \ + frontend/supabase/migrations/20260718102000_recover_missing_profile_rows.sql \ + frontend/supabase/migrations/20260718103000_profile_birth_time_declaration_grants.sql \ + frontend/supabase/migrations/20260718104000_chart_profiles_upsert_id_grant.sql \ + frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql \ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/tmp/profile-migrations/" - name: Apply profile migrations using VPS database URL @@ -66,11 +70,15 @@ jobs: exit 1 fi for SQL_FILE in \ - tmp/profile-migrations/20260718010000_recover_missing_profile_rows.sql \ - tmp/profile-migrations/20260718020000_profiles_service_role_upsert_grants.sql \ tmp/profile-migrations/20260718050000_profiles_service_role_upsert_grants.sql \ + tmp/profile-migrations/20260718060000_profiles_service_role_least_privilege.sql \ tmp/profile-migrations/20260718070000_profiles_service_role_upsert_id.sql \ - tmp/profile-migrations/20260718080000_profiles_service_role_account_upsert_selects.sql + tmp/profile-migrations/20260718080000_profiles_service_role_account_upsert_selects.sql \ + tmp/profile-migrations/20260718100000_repair_missing_chart_profiles.sql \ + tmp/profile-migrations/20260718102000_recover_missing_profile_rows.sql \ + tmp/profile-migrations/20260718103000_profile_birth_time_declaration_grants.sql \ + tmp/profile-migrations/20260718104000_chart_profiles_upsert_id_grant.sql \ + tmp/profile-migrations/20260721100000_chat_sessions_delete_grant.sql do echo "applying $(basename "$SQL_FILE")" cat "$SQL_FILE" | docker run --rm -i postgres:16-alpine \ diff --git a/.github/workflows/backend-quality-gate.yml b/.github/workflows/backend-quality-gate.yml new file mode 100644 index 00000000..f82c5f3f --- /dev/null +++ b/.github/workflows/backend-quality-gate.yml @@ -0,0 +1,164 @@ +name: Staging Backend Quality Gate + +on: + pull_request: + paths: + - '.github/workflows/backend-quality-gate.yml' + - '.github/workflows/deploy-staging.yml' + - '.github/workflows/migrate-staging-database.yml' + - 'deploy/**' + - 'frontend/**' + - 'jyotish_vedic/**' + - 'scripts/**' + - 'tests/**' + - 'mcp_server.py' + - 'pyproject.toml' + - 'requirements*.txt' + push: + branches: [staging] + workflow_dispatch: + +concurrency: + group: backend-quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements-dev.txt + npm ci --prefix frontend + + - name: Run Python quick quality gate + shell: bash + run: | + set -o pipefail + ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \ + tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \ + tests/test_yoga_rules_integrity.py + python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py + mkdir -p artifacts + python scripts/run_quality_gate.py \ + --profile quick --skip-yoga-logic --skip-frontend-runtime \ + 2>&1 | tee artifacts/quick-quality-gate.log + python -m build + + - name: Upload quick quality gate diagnostics + if: always() + uses: actions/upload-artifact@v4 + with: + name: quick-quality-gate-diagnostics + path: artifacts/quick-quality-gate.log + + - name: Validate frontend and database contracts + env: + NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder + run: | + npm test --prefix frontend + npm run lint --prefix frontend + npm run build --prefix frontend + + publish: + if: github.event_name == 'push' && github.ref == 'refs/heads/staging' + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate staging web build variables + env: + STAGING_SUPABASE_URL: ${{ vars.STAGING_SUPABASE_URL }} + STAGING_SUPABASE_ANON_KEY: ${{ vars.STAGING_SUPABASE_ANON_KEY }} + run: | + test -n "$STAGING_SUPABASE_URL" || { + echo "STAGING_SUPABASE_URL is required" >&2 + exit 1 + } + test -n "$STAGING_SUPABASE_ANON_KEY" || { + echo "STAGING_SUPABASE_ANON_KEY is required" >&2 + exit 1 + } + if [[ ! "$STAGING_SUPABASE_URL" =~ ^https://[a-z0-9][a-z0-9-]*\.supabase\.co/?$ ]]; then + echo "STAGING_SUPABASE_URL must be an HTTPS Supabase project URL" >&2 + exit 1 + fi + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and publish API image + id: api_build + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/railway-api.Dockerfile + push: true + tags: ghcr.io/jesse-ux/jyotisha-api:${{ github.sha }} + + - name: Build and publish web image + id: web_build + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/railway-web.Dockerfile + push: true + tags: ghcr.io/jesse-ux/jyotisha-web:${{ github.sha }} + build-args: | + NEXT_PUBLIC_SUPABASE_URL=${{ vars.STAGING_SUPABASE_URL }} + NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ vars.STAGING_SUPABASE_ANON_KEY }} + + - name: Record immutable staging image manifest + env: + API_DIGEST: ${{ steps.api_build.outputs.digest }} + WEB_DIGEST: ${{ steps.web_build.outputs.digest }} + run: | + set -euo pipefail + [[ "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$API_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "$WEB_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] + install -d -m 700 artifacts/staging-images + umask 077 + printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' \ + "$GITHUB_SHA" "$API_DIGEST" "$WEB_DIGEST" \ + > artifacts/staging-images/manifest.env + node frontend/scripts/staging-image-manifest.mjs \ + artifacts/staging-images/manifest.env "$GITHUB_SHA" >/dev/null + + - name: Upload immutable staging image manifest + uses: actions/upload-artifact@v4 + with: + name: staging-image-manifest-${{ github.sha }}-${{ github.run_attempt }} + path: artifacts/staging-images/manifest.env + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bae554e4..1ca5930f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: Jyotish Skill CI on: + push: + branches: [staging] workflow_dispatch: jobs: diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index c5f65174..8345ffc8 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -27,7 +27,21 @@ jobs: with: ref: ${{ github.sha }} + - name: Reject stale CI revision + id: revision + run: | + tested_sha="$(git rev-parse HEAD)" + main_sha="$(git ls-remote origin refs/heads/main | awk '{print $1}')" + if [ "$tested_sha" = "$main_sha" ]; then + echo "deploy=true" >> "$GITHUB_OUTPUT" + echo "Deploying current main revision $tested_sha" + else + echo "deploy=false" >> "$GITHUB_OUTPUT" + echo "Skipping stale CI revision $tested_sha; current main is $main_sha" + fi + - name: Configure SSH + if: steps.revision.outputs.deploy == 'true' env: SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }} run: | @@ -37,6 +51,7 @@ jobs: printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts - name: Sync and rebuild + if: steps.revision.outputs.deploy == 'true' env: DEPLOY_GIT_SHA: ${{ github.sha }} run: | @@ -54,9 +69,19 @@ jobs: "cd '$DEPLOY_PATH' && GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build --remove-orphans" - name: Verify production + if: steps.revision.outputs.deploy == 'true' + env: + DEPLOY_GIT_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} run: | curl --fail --silent --show-error --retry 12 --retry-delay 5 https://jyotisha.chat/login >/dev/null test "$(curl --silent --output /dev/null --write-out '%{http_code}' https://jyotisha.chat/api/account)" = "401" + deployed_sha="" + for attempt in $(seq 1 24); do + deployed_sha="$(curl --fail --silent --show-error https://jyotisha.chat/api/health | python3 -c 'import json, sys; print(json.load(sys.stdin).get("deployment", {}).get("gitCommit", ""))')" || deployed_sha="" + [ "$deployed_sha" = "$DEPLOY_GIT_SHA" ] && break + sleep 5 + done + test "$deployed_sha" = "$DEPLOY_GIT_SHA" || { echo "Production revision did not converge: expected $DEPLOY_GIT_SHA, got ${deployed_sha:-empty}" >&2; exit 1; } ssh -i ~/.ssh/jyotisha-production -p "$DEPLOY_PORT" \ -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20 \ "$DEPLOY_USER@$DEPLOY_HOST" \ diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml new file mode 100644 index 00000000..25842b41 --- /dev/null +++ b/.github/workflows/deploy-staging.yml @@ -0,0 +1,253 @@ +name: Deploy staging + +on: + workflow_run: + workflows: ["Staging Backend Quality Gate"] + types: [completed] + workflow_dispatch: + inputs: + deploy_sha: + description: Exact tested 40-character staging commit SHA + required: true + type: string + allow_rollback: + description: Explicitly permit a manual rollback to an older tested SHA + required: true + default: false + type: boolean + +permissions: + contents: read + actions: read + packages: read + +concurrency: + group: staging-mutation + cancel-in-progress: false + queue: max + +jobs: + deploy: + if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push' && github.event.workflow_run.head_branch == 'staging') + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: staging + url: ${{ vars.STAGING_URL }} + env: + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_URL: ${{ vars.STAGING_URL }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + + steps: + - name: Validate tested revision and gate run + id: revision + env: + REQUESTED_SHA: ${{ github.event.workflow_run.head_sha || inputs.deploy_sha }} + WORKFLOW_RUN_ID: ${{ github.event.workflow_run.id }} + WORKFLOW_RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + REQUESTED_ROLLBACK: ${{ inputs.allow_rollback || 'false' }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + [[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "deploy_sha must be a lowercase full commit SHA" >&2 + exit 1 + } + allow_rollback=false + if [ "$REQUESTED_ROLLBACK" = "true" ]; then + [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] || { + echo "rollback authorization is manual-only" >&2 + exit 1 + } + allow_rollback=true + fi + + gate_run_id="$WORKFLOW_RUN_ID" + gate_run_attempt="$WORKFLOW_RUN_ATTEMPT" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + runs="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/backend-quality-gate.yml/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&per_page=100")" + selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" ' + [.workflow_runs[] | select( + .head_sha == $sha and .head_branch == "staging" and + .event == "push" and .conclusion == "success" + )] | sort_by(.id) | reverse | first + ' <<<"$runs")" + gate_run_id="$(jq -er '.id' <<<"$selected_run")" + gate_run_attempt="$(jq -er '.run_attempt' <<<"$selected_run")" + fi + [[ "$gate_run_id" =~ ^[0-9]+$ ]] || { + echo "no successful exact-SHA staging quality gate run found" >&2 + exit 1 + } + [[ "$gate_run_attempt" =~ ^[1-9][0-9]*$ ]] || { + echo "invalid staging quality gate run attempt" >&2 + exit 1 + } + + staging_head="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/staging" | + jq -er '.object.sha')" + if [ "$allow_rollback" = "false" ] && [ "$REQUESTED_SHA" != "$staging_head" ]; then + echo "stale staging revision refused; use explicit manual rollback only when intended" >&2 + exit 1 + fi + + { + echo "sha=$REQUESTED_SHA" + echo "gate_run_id=$gate_run_id" + echo "gate_run_attempt=$gate_run_attempt" + echo "allow_rollback=$allow_rollback" + } >>"$GITHUB_OUTPUT" + + - name: Checkout trusted main controller + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Download gate-produced image manifest + uses: actions/download-artifact@v4 + with: + name: staging-image-manifest-${{ steps.revision.outputs.sha }}-${{ steps.revision.outputs.gate_run_attempt }} + path: artifacts/staging-image + github-token: ${{ github.token }} + run-id: ${{ steps.revision.outputs.gate_run_id }} + + - name: Validate immutable image manifest + id: images + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + node frontend/scripts/staging-image-manifest.mjs \ + artifacts/staging-image/manifest.env "$DEPLOY_SHA" >>"$GITHUB_OUTPUT" + + - name: Verify reviewed revision and staging target + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + git cat-file -e "$DEPLOY_SHA^{commit}" + git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { + echo "staging revision is not in the reviewed main history" >&2 + exit 1 + } + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" + test "$STAGING_URL" = "https://staging.jyotisha.chat" + test -n "$STAGING_KNOWN_HOSTS" + + - name: Configure pinned staging SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + test -n "$SSH_PRIVATE_KEY" + install -m 700 -d ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + chmod 600 ~/.ssh/jyotisha-staging + printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Verify forward-only deployed revision + id: previous + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + previous_sha="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then value=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"\${value:-not-deployed}\"; else printf not-deployed; fi; fi")" + if [ "$previous_sha" != "not-deployed" ] && [[ ! "$previous_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "invalid deployed staging revision state" >&2 + exit 1 + fi + forward_verified=true + if [ "$ALLOW_ROLLBACK" = "false" ] && + [ "$previous_sha" != "not-deployed" ] && + [ "$previous_sha" != "$DEPLOY_SHA" ]; then + comparison="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")" + jq -e --arg base "$previous_sha" ' + .status == "ahead" and .merge_base_commit.sha == $base + ' <<<"$comparison" >/dev/null || { + echo "automatic staging rollback or divergent deploy refused" >&2 + exit 1 + } + fi + { + echo "sha=$previous_sha" + echo "forward_verified=$forward_verified" + } >>"$GITHUB_OUTPUT" + + - name: Stage trusted controller files in an isolated incoming directory + id: incoming + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + RSYNC_SSH="ssh $SSH_OPTIONS" + incoming="$DEPLOY_PATH/.incoming/$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$incoming'" + echo "path=$incoming" >>"$GITHUB_OUTPUT" + rsync -az --delete --prune-empty-dirs \ + --include='/deploy/' --include='/deploy/***' --exclude='*' \ + -e "$RSYNC_SSH" ./ "$DEPLOY_USER@$DEPLOY_HOST:$incoming/" + + - name: Log in to GHCR with run-local Docker state + env: + GHCR_TOKEN: ${{ github.token }} + INCOMING_PATH: ${{ steps.incoming.outputs.path }} + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$INCOMING_PATH/.docker'" + printf '%s' "$GHCR_TOKEN" | ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DOCKER_CONFIG='$INCOMING_PATH/.docker' docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin" + + - name: Deploy and verify exact image digests under host lock + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + API_IMAGE: ${{ steps.images.outputs.api_image }} + WEB_IMAGE: ${{ steps.images.outputs.web_image }} + ALLOW_ROLLBACK: ${{ steps.revision.outputs.allow_rollback }} + EXPECTED_PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} + FORWARD_REVISION_VERIFIED: ${{ steps.previous.outputs.forward_verified }} + INCOMING_PATH: ${{ steps.incoming.outputs.path }} + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "INCOMING_PATH='$INCOMING_PATH' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$EXPECTED_PREVIOUS_SHA' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$FORWARD_REVISION_VERIFIED' DOCKER_CONFIG='$INCOMING_PATH/.docker' STAGING_URL='$STAGING_URL' bash '$INCOMING_PATH/deploy/run-staging-deploy.sh'" | + tee staging-deploy-result.txt + sed 's/^/- /' staging-deploy-result.txt >>"$GITHUB_STEP_SUMMARY" + + - name: Remove run-local staging files + if: always() && steps.incoming.outputs.path != '' + continue-on-error: true + env: + INCOMING_PATH: ${{ steps.incoming.outputs.path }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DOCKER_CONFIG='$INCOMING_PATH/.docker' docker logout ghcr.io >/dev/null 2>&1 || true; rm -rf -- '$INCOMING_PATH'" diff --git a/.github/workflows/migrate-staging-database.yml b/.github/workflows/migrate-staging-database.yml new file mode 100644 index 00000000..0072cbff --- /dev/null +++ b/.github/workflows/migrate-staging-database.yml @@ -0,0 +1,233 @@ +name: Migrate Staging Database + +on: + workflow_dispatch: + inputs: + deploy_sha: + description: Full tested staging commit SHA to migrate + required: true + type: string + +concurrency: + group: staging-mutation + cancel-in-progress: false + queue: max + +permissions: + contents: read + actions: write + packages: read + +jobs: + migrate: + environment: staging + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + + steps: + - name: Validate current tested staging revision + id: revision + env: + REQUESTED_SHA: ${{ inputs.deploy_sha }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + [[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "deploy_sha must be a lowercase full commit SHA" >&2 + exit 1 + } + runs="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/backend-quality-gate.yml/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&per_page=100")" + selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" ' + [.workflow_runs[] | select( + .head_sha == $sha and .head_branch == "staging" and + .event == "push" and .conclusion == "success" + )] | sort_by(.id) | reverse | first + ' <<<"$runs")" + gate_run_id="$(jq -er '.id' <<<"$selected_run")" + gate_run_attempt="$(jq -er '.run_attempt' <<<"$selected_run")" + [[ "$gate_run_id" =~ ^[0-9]+$ ]] + [[ "$gate_run_attempt" =~ ^[1-9][0-9]*$ ]] + staging_head="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/staging" | + jq -er '.object.sha')" + [ "$REQUESTED_SHA" = "$staging_head" ] || { + echo "stale staging migration refused; migrate the current staging head" >&2 + exit 1 + } + { + echo "sha=$REQUESTED_SHA" + echo "gate_run_id=$gate_run_id" + echo "gate_run_attempt=$gate_run_attempt" + } >>"$GITHUB_OUTPUT" + + - name: Checkout trusted main controller + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + persist-credentials: false + + - name: Download gate-produced image manifest + uses: actions/download-artifact@v4 + with: + name: staging-image-manifest-${{ steps.revision.outputs.sha }}-${{ steps.revision.outputs.gate_run_attempt }} + path: artifacts/staging-image + github-token: ${{ github.token }} + run-id: ${{ steps.revision.outputs.gate_run_id }} + + - name: Validate immutable migration image + id: images + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + node frontend/scripts/staging-image-manifest.mjs \ + artifacts/staging-image/manifest.env "$DEPLOY_SHA" >>"$GITHUB_OUTPUT" + + - name: Verify reviewed revision and staging target + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + git cat-file -e "$DEPLOY_SHA^{commit}" + git merge-base --is-ancestor "$DEPLOY_SHA" HEAD || { + echo "staging revision is not in the reviewed main history" >&2 + exit 1 + } + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" + test -n "$STAGING_KNOWN_HOSTS" + + - name: Configure pinned staging SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + test -n "$SSH_PRIVATE_KEY" + install -m 700 -d ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + chmod 600 ~/.ssh/jyotisha-staging + printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Verify forward-only migration revision + id: previous + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + previous_sha="$(ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then value=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"\${value:-not-deployed}\"; else printf not-deployed; fi; fi")" + if [ "$previous_sha" != "not-deployed" ] && [[ ! "$previous_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "invalid deployed staging revision state" >&2 + exit 1 + fi + if [ "$previous_sha" != "not-deployed" ] && [ "$previous_sha" != "$DEPLOY_SHA" ]; then + comparison="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")" + jq -e --arg base "$previous_sha" ' + .status == "ahead" and .merge_base_commit.sha == $base + ' <<<"$comparison" >/dev/null || { + echo "stale or divergent staging migration refused" >&2 + exit 1 + } + fi + { + echo "sha=$previous_sha" + echo "forward_verified=true" + } >>"$GITHUB_OUTPUT" + + - name: Stage trusted controller files in an isolated incoming directory + id: incoming + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + RSYNC_SSH="ssh $SSH_OPTIONS" + incoming="$DEPLOY_PATH/.incoming/$GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$incoming'" + echo "path=$incoming" >>"$GITHUB_OUTPUT" + rsync -az --delete --prune-empty-dirs \ + --include='/deploy/' --include='/deploy/***' --exclude='*' \ + -e "$RSYNC_SSH" ./ "$DEPLOY_USER@$DEPLOY_HOST:$incoming/" + + - name: Log in to GHCR with run-local Docker state + env: + GHCR_TOKEN: ${{ github.token }} + INCOMING_PATH: ${{ steps.incoming.outputs.path }} + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 700 '$INCOMING_PATH/.docker'" + printf '%s' "$GHCR_TOKEN" | ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DOCKER_CONFIG='$INCOMING_PATH/.docker' docker login ghcr.io --username '$GITHUB_ACTOR' --password-stdin" + + - name: Apply exact-image migrations under host lock + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + WEB_IMAGE: ${{ steps.images.outputs.web_image }} + EXPECTED_PREVIOUS_SHA: ${{ steps.previous.outputs.sha }} + FORWARD_REVISION_VERIFIED: ${{ steps.previous.outputs.forward_verified }} + INCOMING_PATH: ${{ steps.incoming.outputs.path }} + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "INCOMING_PATH='$INCOMING_PATH' DEPLOY_PATH='$DEPLOY_PATH' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$EXPECTED_PREVIOUS_SHA' FORWARD_REVISION_VERIFIED='$FORWARD_REVISION_VERIFIED' DOCKER_CONFIG='$INCOMING_PATH/.docker' bash '$INCOMING_PATH/deploy/run-staging-migration.sh'" + + - name: Dispatch current exact-SHA staging deployment + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + staging_head="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/git/ref/heads/staging" | + jq -er '.object.sha')" + [ "$DEPLOY_SHA" = "$staging_head" ] || { + echo "staging advanced during migration; refusing stale deployment dispatch" >&2 + exit 1 + } + payload="$(jq -cn --arg deploy_sha "$DEPLOY_SHA" \ + '{ref:"main",inputs:{deploy_sha:$deploy_sha,allow_rollback:"false"}}')" + curl --fail --silent --show-error --request POST \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + --header "Content-Type: application/json" \ + --data "$payload" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/deploy-staging.yml/dispatches" + + - name: Remove run-local staging files + if: always() && steps.incoming.outputs.path != '' + continue-on-error: true + env: + INCOMING_PATH: ${{ steps.incoming.outputs.path }} + run: | + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DOCKER_CONFIG='$INCOMING_PATH/.docker' docker logout ghcr.io >/dev/null 2>&1 || true; rm -rf -- '$INCOMING_PATH'" diff --git a/.gitignore b/.gitignore index 536c62b3..a1818814 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,7 @@ brain/ COVERAGE_AUDIT_REPORT.md *.tmp .env.local +.env.staging.database .jyotish.local.env .coverage coverage.xml diff --git a/AGENTS.md b/AGENTS.md index 0164f7f1..ff114603 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Deployment safety rules: 3. Keep Supabase Auth Site URL and redirect URLs aligned with `https://jyotisha.chat`. 4. After deployment, verify `/login`, logged-out `/api/account` = `401`, internal `/api/health` = `200`, and `swisseph_available = true`. 5. Never expose port `5200`, `SUPABASE_SERVICE_ROLE_KEY`, model keys, user JWTs, passwords, or SSH private keys. -6. GitHub Actions workflows are manual-only. Run the required validation workflows from the Actions page before manually starting production deployment; the production workflow and required secret are documented in `deploy/README.md`. +6. Production GitHub Actions validation, deployment, and migration workflows are manual-only. The explicitly authorized staging `Staging Backend Quality Gate` may run automatically for pull requests and pushes to `staging`, and a successful staging gate may automatically trigger `Deploy staging`; `Migrate Staging Database` remains manual-only. Run the required production validation workflows from the Actions page before manually starting production deployment; the production workflow and required secret are documented in `deploy/README.md`. ## 1. High-Rigor Override diff --git a/REPO_LAYOUT.md b/REPO_LAYOUT.md index 5657c79e..22fe0078 100644 --- a/REPO_LAYOUT.md +++ b/REPO_LAYOUT.md @@ -4,6 +4,10 @@ This repository mixes product code, astrology research, oracle artifacts, and lo ## Core Areas +- `/frontend/` + - current Next.js production web application +- `/deploy/` + - production container topology and operational source of truth; start with `deploy/README.md` - `/mcp_server.py` - adjudicator-facing MCP entrypoint - `/scripts/` @@ -20,6 +24,16 @@ This repository mixes product code, astrology research, oracle artifacts, and lo - `/docs/research/archive/` - historical round notes and local draft research +## Historical Working Logs + +- `/task_plan.md` +- `/findings.md` +- `/progress.md` + +These root files preserve earlier implementation history and may mention retired +paths or commands. They are not runtime or deployment instructions. Use +`README.md` for current local development and `deploy/README.md` for production. + ## Local Scratch - `/scratch/local/scripts/` diff --git a/artifacts/real_case_site_e2e_summary/real_case_website_e2e_capture_summary_2026_07_20.json b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_capture_summary_2026_07_20.json new file mode 100644 index 00000000..4839f2fc --- /dev/null +++ b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_capture_summary_2026_07_20.json @@ -0,0 +1,23 @@ +{ + "batch_offsets": [ + 0, + 20, + 40 + ], + "boundary": "Runtime context capture summary only; public cases are product E2E QA references, not prediction accuracy proof.", + "captured_count": 60, + "contract": "references/real_case_calibration/real_case_website_e2e_eval_2026_07_20.json", + "core_status_counts": { + "ready": 60 + }, + "created_at": "2026-07-20", + "missing_route_layer_total": 0, + "production_tuning_allowed": false, + "route_counts": { + "career": 51, + "finance": 7, + "relationship": 2 + }, + "scope": "real_case_website_e2e_capture_summary", + "truth_matrix_allowed": false +} diff --git a/artifacts/real_case_site_e2e_summary/real_case_website_e2e_extended_route_smoke_summary_2026_07_20.json b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_extended_route_smoke_summary_2026_07_20.json new file mode 100644 index 00000000..0ff3beb1 --- /dev/null +++ b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_extended_route_smoke_summary_2026_07_20.json @@ -0,0 +1,26 @@ +{ + "batch_max_items": 10, + "batch_offsets": [ + 0, + 20, + 40 + ], + "boundary": "Extended route smoke for migration/family/education/annual/health real-case E2E; product QA only.", + "captured_count": 30, + "core_status_counts": { + "ready": 30 + }, + "created_at": "2026-07-20", + "missing_route_layer_total": 0, + "production_tuning_allowed": false, + "route_counts": { + "career": 7, + "education": 1, + "family": 1, + "finance": 4, + "migration": 4, + "timing": 13 + }, + "scope": "real_case_website_e2e_extended_route_smoke_summary", + "truth_matrix_allowed": false +} diff --git a/artifacts/real_case_site_e2e_summary/real_case_website_e2e_full_route_optimization_summary_2026_07_20.json b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_full_route_optimization_summary_2026_07_20.json new file mode 100644 index 00000000..29b14451 --- /dev/null +++ b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_full_route_optimization_summary_2026_07_20.json @@ -0,0 +1,23 @@ +{ + "batch_offsets": [ + 0, + 20, + 40 + ], + "boundary": "Full 60-context public real-case E2E route optimization summary; product QA only, not prediction accuracy proof.", + "captured_count": 60, + "core_status_counts": { + "ready": 60 + }, + "created_at": "2026-07-20", + "missing_route_layer_total": 0, + "production_tuning_allowed": false, + "route_counts": { + "career": 4, + "finance": 24, + "relationship": 16, + "timing": 16 + }, + "scope": "real_case_website_e2e_full_route_optimization_summary", + "truth_matrix_allowed": false +} diff --git a/artifacts/real_case_site_e2e_summary/real_case_website_e2e_health_route_summary_2026_07_20.json b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_health_route_summary_2026_07_20.json new file mode 100644 index 00000000..6cb3b1e1 --- /dev/null +++ b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_health_route_summary_2026_07_20.json @@ -0,0 +1,20 @@ +{ + "batch_max_items": 20, + "batch_offset": 40, + "boundary": "Health route smoke summary for public real-case E2E; product QA only, not medical or prediction proof.", + "captured_count": 20, + "core_status_counts": { + "ready": 20 + }, + "created_at": "2026-07-20", + "missing_route_layer_total": 0, + "production_tuning_allowed": false, + "route_counts": { + "finance": 7, + "health": 4, + "relationship": 5, + "timing": 4 + }, + "scope": "real_case_website_e2e_health_route_summary", + "truth_matrix_allowed": false +} diff --git a/artifacts/real_case_site_e2e_summary/real_case_website_e2e_route_optimization_summary_2026_07_20.json b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_route_optimization_summary_2026_07_20.json new file mode 100644 index 00000000..9d279a97 --- /dev/null +++ b/artifacts/real_case_site_e2e_summary/real_case_website_e2e_route_optimization_summary_2026_07_20.json @@ -0,0 +1,17 @@ +{ + "batch_max_items": 20, + "batch_offset": 0, + "boundary": "Route optimization smoke summary for public real-case E2E capture; not prediction accuracy proof.", + "captured_count": 20, + "core_status_counts": { + "ready": 20 + }, + "created_at": "2026-07-20", + "missing_route_layer_total": 0, + "route_counts": { + "finance": 8, + "relationship": 4, + "timing": 8 + }, + "scope": "real_case_website_e2e_route_optimization_summary" +} diff --git a/deploy/Caddyfile.staging b/deploy/Caddyfile.staging new file mode 100644 index 00000000..66bbb59c --- /dev/null +++ b/deploy/Caddyfile.staging @@ -0,0 +1,4 @@ +{$SITE_ADDRESS:https://staging.jyotisha.chat} { + encode zstd gzip + reverse_proxy web:3000 +} diff --git a/deploy/README.md b/deploy/README.md index 7c37ff94..704e59a5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -120,7 +120,7 @@ The server has a persistent 2 GB `/swapfile`. UFW permits only SSH `22000/tcp`, ## Manual deployment with GitHub Actions -Pushes and pull requests do not start GitHub Actions automatically. Run the required validation workflows from the Actions page, then manually start `.github/workflows/deploy-production.yml` for the tested branch. The deployment workflow syncs that revision with `rsync`, preserves `/opt/jyotisha-app/.env.production`, rebuilds both Docker services, and verifies the public login route, logged-out account response, and private Python health endpoint. +Production pushes and pull requests do not start GitHub Actions automatically. Run the required validation workflows from the Actions page, then manually start `.github/workflows/deploy-production.yml` for the tested branch. The deployment workflow syncs that revision with `rsync`, preserves `/opt/jyotisha-app/.env.production`, rebuilds both Docker services, and verifies the public login route, logged-out account response, and private Python health endpoint. Required GitHub Actions secret: @@ -130,6 +130,171 @@ PRODUCTION_SSH_PRIVATE_KEY = dedicated production deploy private key The workflow pins the VPS Ed25519 host key and serializes deployments with the `production` concurrency group. +## Staging deployment + +Staging is isolated from production: + +| Item | Value | +| --- | --- | +| URL | `https://staging.jyotisha.chat` | +| Host | `118.26.111.127` | +| Path | `/opt/jyotisha-staging` | +| Runtime app env | `/opt/jyotisha-staging/.env.staging` (`0600`) | +| Runtime database env | `/opt/jyotisha-staging/.env.staging.database` (`0600`) | +| PostgreSQL | private Compose network; no published host port | +| Supabase | separate `Jyotisha Staging` project | +| GitHub Environment | `staging` | + +The GitHub `staging` Environment contains the secret `STAGING_SSH_PRIVATE_KEY` and the variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. Its deployment branch policy allows the `main` controller branch: GitHub's `workflow_run` event executes from the default branch while the workflow separately requires the successfully tested upstream branch to be `staging`. The controller checks out only `main` with full history, requires the requested staging SHA to be an ancestor of that reviewed history, and uploads only the allowlisted `deploy/` control files. It never executes deployment validators or remote orchestration scripts from the target/rollback revision. The staging key, database, Supabase keys, and model-provider keys must not be shared with production. + +The repository-level public build inputs are configured at GitHub **Settings -> Secrets and variables -> Actions -> Variables** (the UI is also shown as **Settings → Secrets and variables → Actions → Variables**): `STAGING_SUPABASE_URL` and `STAGING_SUPABASE_ANON_KEY`. They are public build inputs, required for publish, and exposed to the browser; keep them staging-only and never print their values in workflow output, summaries, or support messages. The workflow passes them only as the `NEXT_PUBLIC_*` build arguments after non-empty/HTTPS validation. + +`Staging Backend Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound manifest containing their `sha256` digests. `.github/workflows/deploy-staging.yml` consumes that exact successful run, validates its manifest against the full 40-character commit, and deploys digest references rather than trusting the discoverability tags. + +The staging env file must include these non-secret selectors so Compose cannot fall back to production paths: + +```dotenv +APP_ENV_FILE=../.env.staging +CADDYFILE_PATH=./Caddyfile.staging +SITE_ADDRESS=https://staging.jyotisha.chat +``` + +After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA. + +### First-deploy sequence + +1. Complete the server and GitHub bootstrap: create both mode-`0600` env files, preload the reviewed `postgres:17-alpine` image, configure the staging Environment variables/secrets, and configure the repository staging build variables. Deployment and migration workflows use `--pull never` for PostgreSQL, so database image upgrades remain an explicit operator-controlled maintenance action rather than an application-deploy side effect. +2. Merge the reviewed change to `main`, then fast-forward/push that exact reviewed SHA to `staging`; do not create a staging-only target or rely on a `main` workflow dispatch to publish images. +3. The `Staging Backend Quality Gate` runs for that push and, when successful, publishes API/web images plus an artifact binding the exact SHA to both immutable image digests. +4. The automatic `Deploy staging` workflow downloads that gate-run artifact, syncs only the trusted `main` controller's allowlisted `deploy/` files under the shared staging host lock, and validates both `.env.staging` and `.env.staging.database` before any app change. The target application's code is carried only by the digest-pinned images. +5. If environment validation fails, fix the server-side env files without committing or copying secrets, then manually rerun `Deploy staging` from `main` with the same successful SHA in `deploy_sha`; the workflow rechecks a successful staging gate for that exact SHA. +6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually with the same full SHA; a successful migration re-dispatches `Deploy staging` with that same SHA. +7. Confirm `https://staging.jyotisha.chat/api/health` reports the exact SHA and private API health. + +Application rollback uses the same workflow: manually dispatch `Deploy staging` from the `main` controller with a previous known-good full SHA that has a successful `Staging Backend Quality Gate` run, and explicitly set `allow_rollback=true`. Normal and migration-triggered deployments reject stale, divergent, or backward revisions. Rollback still consumes the selected gate run's digest manifest and is supported only during that artifact's 30-day retention window; after expiry, stop and prepare a separately reviewed republish/recovery change rather than substituting a mutable tag or assuming the old run can still be rerun. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal. + +Inspect staging without printing secrets: + +```bash +ssh -i ~/.ssh/jyotisha-staging deploy@118.26.111.127 +cd /opt/jyotisha-staging +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml ps +docker compose --env-file .env.staging -f deploy/docker-compose.server.yml logs --tail=100 api web caddy +curl -fsS https://staging.jyotisha.chat/api/health +``` + +The normal application deployment workflow never runs database migrations. Apply migrations to the separate staging project first, verify them, and only then deploy application code that depends on them. + +## Staging PostgreSQL operations + +This section is the server-side runbook for the disposable staging PostgreSQL volume. It does not replace the production instructions above. + +### Bootstrap and environment-file boundary + +SSH to the staging host as the deployment user and create both environment files with a restrictive umask. The application file and the database file are separate, both are mode `0600`, the database file is owned by the deployment user, and neither is committed or copied through `rsync`: + +```bash +cd /opt/jyotisha-staging +umask 077 +touch .env.staging +chmod 600 .env.staging +touch .env.staging.database +chmod 600 .env.staging.database +``` + +`.env.staging` contains application selectors and server-only application credentials. `SCHEMA_DATABASE_URL` must not appear in `.env.staging`; neither may any database bootstrap password, `STAGING_BACKUP_ENCRYPTION_KEY`, or migration-runner credential. In particular, there is no `SCHEMA_DATABASE_URL` in `.env.staging`; the schema URL exists only in `.env.staging.database`, which is read by PostgreSQL and the opt-in migrator. + +Generate every `` value from independently generated 32 random bytes (for example, run `openssl rand -base64 32` separately for each value and place it directly into the mode-`0600` file or an approved secret store). Do not reuse a password between roles, paste values into chat, commit either file, or print them in workflow logs. The schema-owner password in `SCHEMA_DATABASE_URL` is the same secret as `SCHEMA_OWNER_PASSWORD`; use a percent-encoded URL password component only, and do not encode the scheme, host, port, or database name. + +The exact database keys are: + +```dotenv +POSTGRES_DB=jyotisha +POSTGRES_USER=postgres +POSTGRES_PASSWORD= +SCHEMA_OWNER_PASSWORD= +IDENTITY_RUNTIME_PASSWORD= +APP_RUNTIME_PASSWORD= +ADMIN_RUNTIME_PASSWORD= +MIGRATION_RUNNER_PASSWORD= +BACKUP_READER_PASSWORD= +STAGING_BACKUP_ENCRYPTION_KEY= +SCHEMA_DATABASE_URL=postgresql://schema_owner:@postgres:5432/jyotisha +``` + +PostgreSQL is private: `deploy/docker-compose.postgres.yml` has no `ports` mapping, so the staging database is reachable only on the Docker `app` network. The CI overlay is the only host binding and is loopback-only (`127.0.0.1:${POSTGRES_HOST_PORT:-55432}:5432`); do not add a public database port, firewall exception, or browser-facing SQL tool. Normal web/API containers never receive `SCHEMA_DATABASE_URL`. + +### Exact deployment and migration order + +Use this order for every staging revision: + +1. Merge the reviewed revision to `main`, then fast-forward/push that same exact SHA to `staging`. +2. Wait for `Staging Backend Quality Gate` to pass and publish that exact full SHA's API/web digest manifest. +3. The automatic `Deploy staging` workflow checks the exact SHA in read-only migration-check mode before changing API, web, or Caddy. If it reports pending or drifted migrations, stop; do not retry the application deployment as if it were a migration. +4. Open **Migrate Staging Database -> Run workflow**, select **Use workflow from: main**, and enter the reported full lowercase 40-character SHA in `deploy_sha`. The controller validates that exact SHA against a successful `staging` gate and reviewed `main` history, starts only PostgreSQL, and runs the digest-pinned migrator without executing scripts from the target revision. +5. A successful migration rechecks that `staging` still points at the same exact SHA, prints the ordered migration ledger, and dispatches the `main` controller for digest-pinned deployment with `allow_rollback=false`. If `staging` advanced during migration, it refuses the stale dispatch. Do not substitute a branch name, a short SHA, or a newer commit. +6. Confirm `https://staging.jyotisha.chat/api/health` and verify that its deployment SHA is the SHA from step 2. +7. After health verification, create the local encrypted backup described below. + +The deploy and migration workflows share the `staging-mutation` Actions concurrency group, and their live-tree sync plus Compose work runs under `/opt/jyotisha-staging/.state/mutation.lock`. The synchronized tree explicitly preserves `/backups/`, `.env*`, `.state`, and `.incoming`. The read-only checker exits before app changes when a migration is pending. Its message includes the exact SHA and the `Migrate Staging Database` workflow name. A failed migration does not re-dispatch deployment. Application rollback restores the previously recorded digest references and SHA, falling back to validated local image IDs only when transitioning from the pre-foundation local-image deployment; it does not roll back database state. + +### Local encrypted staging backups (three-copy limit) + +After the health check, run the repository backup helper from the synchronized staging checkout: + +```bash +cd /opt/jyotisha-staging +./deploy/backup-staging-postgres.sh \ + .env.staging.database \ + /opt/jyotisha-staging/backups/staging-db +``` + +The helper invokes `pg_dump --format=custom --no-owner` in the PostgreSQL container and encrypts the stream with `openssl enc -aes-256-cbc -salt -pbkdf2 -pass env:STAGING_BACKUP_ENCRYPTION_KEY`. It creates mode-`0600` `.dump.enc` files in a mode-`0700` directory, refuses disk usage at or above 70%, publishes atomically, and retains only the newest three encrypted local backups. The encryption passphrase is supplied through the environment, never as a command-line argument or printed value. Keep the archive directory on this staging VPS only; there is no off-site staging recovery and no off-site staging backup. These three local encrypted copies are rehearsal/rollback aids, not disaster-recovery backups. + +### Restore drill into a disposable database + +Run a restore drill only against the disposable `jyotisha_restore_check` database. Choose one archive and use a temporary decrypted custom-format dump; the commands below match the backup helper's AES-256-CBC/PBKDF2 and `pg_dump --format=custom` interfaces: + +```bash +set -euo pipefail +cd /opt/jyotisha-staging +export DATABASE_ENV_FILE=../.env.staging.database +BACKUP_DIR=/opt/jyotisha-staging/backups/staging-db +BACKUP_FILE="$(find "$BACKUP_DIR" -maxdepth 1 -type f -name 'jyotisha-staging-*.dump.enc' -print | LC_ALL=C sort | tail -n 1)" +test -n "$BACKUP_FILE" +test -f "$BACKUP_FILE" +test ! -L "$BACKUP_FILE" +test -s "$BACKUP_FILE" +RESTORE_DUMP="$(mktemp /tmp/jyotisha-staging-restore.XXXXXX.dump)" +chmod 600 "$RESTORE_DUMP" +trap 'rm -f -- "$RESTORE_DUMP"' EXIT +read -r -s -p 'Backup passphrase: ' STAGING_BACKUP_ENCRYPTION_KEY +printf '\n' >&2 +export STAGING_BACKUP_ENCRYPTION_KEY + +openssl enc -d -aes-256-cbc -pbkdf2 \ + -pass env:STAGING_BACKUP_ENCRYPTION_KEY \ + -in "$BACKUP_FILE" -out "$RESTORE_DUMP" + +docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml \ + exec -T postgres createdb -U postgres jyotisha_restore_check +docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml \ + exec -T postgres pg_restore -U postgres --no-owner --exit-on-error \ + --dbname=jyotisha_restore_check < "$RESTORE_DUMP" + +# Inspect the restored disposable database, then remove only the drill target. +docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml \ + exec -T postgres dropdb -U postgres --if-exists jyotisha_restore_check +rm -f -- "$RESTORE_DUMP" +unset STAGING_BACKUP_ENCRYPTION_KEY +``` + +The passphrase is read silently into an environment variable; do not put it in argv, shell history, logs, or support messages. The cleanup scope is deliberately narrow: delete only `jyotisha_restore_check` and the temporary decrypted dump. Do not run `docker compose down`, `down -v`, `dropdb jyotisha`, volume deletion, or archive deletion as part of this drill. If restore fails, preserve the encrypted archive and PostgreSQL volume for inspection, remove only the temporary dump, and investigate before retrying. + +### Staging/production boundary + +This disposable staging procedure does not authorize a production migration, production backup policy, production database replacement, domain switch, Supabase deletion, or production cutover. Production deployment and migration remain manual-only and require a separate reviewed approval, off-site encrypted backups, and a successful production restore drill. Keep the production `.env.production` and all production credentials on the production host; never copy them into staging. + ## Manual deployment fallback If GitHub Actions is unavailable, deploy the tracked tree without copying local secrets: diff --git a/deploy/backup-staging-postgres.sh b/deploy/backup-staging-postgres.sh new file mode 100755 index 00000000..75f60e75 --- /dev/null +++ b/deploy/backup-staging-postgres.sh @@ -0,0 +1,248 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x +umask 077 + +if [ "$#" -ne 2 ]; then + echo "usage: backup-staging-postgres.sh DATABASE_ENV_FILE BACKUP_DIRECTORY" >&2 + exit 1 +fi + +SCRIPT_DIRECTORY="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPOSITORY_ROOT="$(cd "$SCRIPT_DIRECTORY/.." && pwd)" +VALIDATOR="$SCRIPT_DIRECTORY/validate-staging-database-env.sh" + +DATABASE_ENV_FILE="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +export DATABASE_ENV_FILE +BACKUP_DIRECTORY_INPUT="$2" + +reject_backup_directory() { + echo "backup directory must be an absolute path without traversal, aliases, or symlinks" >&2 + exit 1 +} + +reject_unsafe_backup_directory_ancestor() { + echo "backup directory ancestor must be owned by the current user or root and not group/world-writable" >&2 + exit 1 +} + +stat_owner_and_mode() { + local path="$1" + + if stat -f '%u %p' "$path" >/dev/null 2>&1; then + stat -f '%u %p' "$path" + else + stat -c '%u %a' "$path" + fi +} + +directory_identity() { + local path="$1" + + if stat -f '%d:%i' "$path" >/dev/null 2>&1; then + stat -f '%d:%i' "$path" + else + stat -c '%d:%i' "$path" + fi +} + +directory_mode_is_group_or_world_writable() { + local mode="$1" + local permissions="${mode: -3}" + + (( (10#${permissions:1:1} & 2) != 0 || (10#${permissions:2:1} & 2) != 0 )) +} + +directory_mode_is_sticky() { + local mode="$1" + + [ "${#mode}" -ge 4 ] && (( (10#${mode: -4:1} & 1) != 0 )) +} + +validate_backup_directory_component() { + local path="$1" + local require_private="$2" + local owner + local mode + + if [ -L "$path" ] || [ ! -d "$path" ]; then + reject_backup_directory + fi + read -r owner mode <<< "$(stat_owner_and_mode "$path")" + if [ "$require_private" = "1" ]; then + if [ "$owner" != "$CURRENT_UID" ] || directory_mode_is_group_or_world_writable "$mode"; then + reject_unsafe_backup_directory_ancestor + fi + return + fi + if [ "$owner" != "$CURRENT_UID" ] && [ "$owner" != "0" ]; then + reject_unsafe_backup_directory_ancestor + fi + if directory_mode_is_group_or_world_writable "$mode" && ! { [ "$owner" = "0" ] && directory_mode_is_sticky "$mode"; }; then + reject_unsafe_backup_directory_ancestor + fi +} + +if [ "$BACKUP_DIRECTORY_INPUT" = "/" ] || [[ "$BACKUP_DIRECTORY_INPUT" != /* ]] || [[ "$BACKUP_DIRECTORY_INPUT" == */ ]] || [[ "$BACKUP_DIRECTORY_INPUT" == *"//"* ]]; then + reject_backup_directory +fi + +IFS='/' read -r -a backup_directory_components <<< "${BACKUP_DIRECTORY_INPUT#/}" +if [ "${#backup_directory_components[@]}" -eq 0 ]; then + reject_backup_directory +fi + +backup_directory_component_path="" +CURRENT_UID="$(id -u)" +FIRST_CREATED_COMPONENT_INDEX=-1 +DEEPEST_EXISTING_COMPONENT_INDEX=-1 +DEEPEST_EXISTING_COMPONENT_PATH="/" +for ((backup_directory_component_index = 0; backup_directory_component_index < ${#backup_directory_components[@]}; backup_directory_component_index += 1)); do + backup_directory_component="${backup_directory_components[$backup_directory_component_index]}" + if [ -z "$backup_directory_component" ] || [ "$backup_directory_component" = "." ] || [ "$backup_directory_component" = ".." ]; then + reject_backup_directory + fi +done + +for ((backup_directory_component_index = 0; backup_directory_component_index < ${#backup_directory_components[@]}; backup_directory_component_index += 1)); do + backup_directory_component="${backup_directory_components[$backup_directory_component_index]}" + backup_directory_component_path="${backup_directory_component_path}/${backup_directory_component}" + if [ -L "$backup_directory_component_path" ]; then + reject_backup_directory + fi + if [ -e "$backup_directory_component_path" ]; then + validate_backup_directory_component "$backup_directory_component_path" 0 + DEEPEST_EXISTING_COMPONENT_INDEX="$backup_directory_component_index" + DEEPEST_EXISTING_COMPONENT_PATH="$backup_directory_component_path" + elif [ "$FIRST_CREATED_COMPONENT_INDEX" -eq -1 ]; then + FIRST_CREATED_COMPONENT_INDEX="$backup_directory_component_index" + break + fi +done + +"$VALIDATOR" "$DATABASE_ENV_FILE" >/dev/null + +read_environment_value() { + local key="$1" + local value + + value="$(sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*(.*)$/\\2/p" "$DATABASE_ENV_FILE")" + case "$value" in + \"*\") value="${value#\"}"; value="${value%\"}" ;; + \'*\') value="${value#\'}"; value="${value%\'}" ;; + esac + printf '%s' "$value" +} + +POSTGRES_DB="$(read_environment_value POSTGRES_DB)" +POSTGRES_USER="$(read_environment_value POSTGRES_USER)" +STAGING_BACKUP_ENCRYPTION_KEY="$(read_environment_value STAGING_BACKUP_ENCRYPTION_KEY)" +export STAGING_BACKUP_ENCRYPTION_KEY + +if [ "$FIRST_CREATED_COMPONENT_INDEX" -eq -1 ]; then + validate_backup_directory_component "$BACKUP_DIRECTORY_INPUT" 1 +fi + +cd -P "$DEEPEST_EXISTING_COMPONENT_PATH" +if [ "$FIRST_CREATED_COMPONENT_INDEX" -ne -1 ]; then + for ((backup_directory_component_index = DEEPEST_EXISTING_COMPONENT_INDEX + 1; backup_directory_component_index < ${#backup_directory_components[@]}; backup_directory_component_index += 1)); do + backup_directory_component="${backup_directory_components[$backup_directory_component_index]}" + if [ -e "./$backup_directory_component" ] || [ -L "./$backup_directory_component" ]; then + reject_backup_directory + fi + if ! mkdir "./$backup_directory_component"; then + reject_backup_directory + fi + validate_backup_directory_component "./$backup_directory_component" 1 + cd -P "./$backup_directory_component" + done +fi +BACKUP_DIRECTORY="$(pwd -P)" +if [ "$BACKUP_DIRECTORY" != "$BACKUP_DIRECTORY_INPUT" ] || [ "$BACKUP_DIRECTORY" = "/" ]; then + reject_backup_directory +fi +BACKUP_DIRECTORY_IDENTITY="$(directory_identity .)" +if [ "$(directory_identity "$BACKUP_DIRECTORY_INPUT")" != "$BACKUP_DIRECTORY_IDENTITY" ]; then + reject_backup_directory +fi +chmod 0700 . + +DISK_USAGE="$(df -Pk . | awk 'NR == 2 { gsub(/%/, "", $5); print $5 }')" +if ! [[ "$DISK_USAGE" =~ ^[0-9]+$ ]] || [ "$DISK_USAGE" -ge 70 ]; then + echo "backup directory disk usage must be below 70 percent" >&2 + exit 1 +fi + +BACKUP_TIMESTAMP="${BACKUP_TIMESTAMP:-$(date -u +%Y%m%dT%H%M%SZ)}" +if ! [[ "$BACKUP_TIMESTAMP" =~ ^[0-9]{8}T[0-9]{6}Z$ ]]; then + echo "backup timestamp must use YYYYMMDDTHHMMSSZ" >&2 + exit 1 +fi + +FILE_NAME="jyotisha-staging-${BACKUP_TIMESTAMP}.dump.enc" +FINAL_FILE="$FILE_NAME" +PARTIAL_FILE=".${FILE_NAME}.$$.partial" +LOCK_DIRECTORY=".${FILE_NAME}.lock" +LOCK_ACQUIRED=0 + +if [ -e "$FINAL_FILE" ] || [ -L "$FINAL_FILE" ]; then + echo "backup destination already exists" >&2 + exit 1 +fi + +cleanup_partial() { + local status="$?" + if [ -n "${PARTIAL_FILE:-}" ] && [ -e "$PARTIAL_FILE" ]; then + rm -f "$PARTIAL_FILE" + fi + if [ "${LOCK_ACQUIRED:-0}" -eq 1 ] && [ -d "$LOCK_DIRECTORY" ]; then + rmdir "$LOCK_DIRECTORY" || true + fi + exit "$status" +} +trap cleanup_partial EXIT HUP INT TERM + +if ! mkdir "$LOCK_DIRECTORY"; then + echo "backup destination is already being created" >&2 + exit 1 +fi +LOCK_ACQUIRED=1 +: > "$PARTIAL_FILE" +chmod 0600 "$PARTIAL_FILE" + +( + cd "$REPOSITORY_ROOT" + docker compose -p "${COMPOSE_PROJECT_NAME:-jyotisha-staging}" \ + -f deploy/docker-compose.postgres.yml exec -T postgres \ + pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner +) | +openssl enc -aes-256-cbc -salt -pbkdf2 \ + -pass env:STAGING_BACKUP_ENCRYPTION_KEY > "$PARTIAL_FILE" + +chmod 0600 "$PARTIAL_FILE" +if ! ln "$PARTIAL_FILE" "$FINAL_FILE"; then + echo "backup destination already exists" >&2 + exit 1 +fi +rm -f "$PARTIAL_FILE" +PARTIAL_FILE="" + +completed=() +if ! completed_paths="$(find . -maxdepth 1 -type f -name 'jyotisha-staging-*.dump.enc' -print | LC_ALL=C sort)"; then + echo "failed to enumerate completed backups" >&2 + exit 1 +fi +while IFS= read -r path; do + name="${path##*/}" + if [[ "$name" =~ ^jyotisha-staging-[0-9]{8}T[0-9]{6}Z\.dump\.enc$ ]]; then + completed+=("$name") + fi +done <<< "$completed_paths" + +if [ "${#completed[@]}" -gt 3 ]; then + for ((index = 0; index < ${#completed[@]} - 3; index += 1)); do + rm -f "${completed[$index]}" + done +fi + +printf 'path=%s count=%s\n' "$BACKUP_DIRECTORY/$FINAL_FILE" "$(( ${#completed[@]} > 3 ? 3 : ${#completed[@]} ))" diff --git a/deploy/docker-compose.postgres-ci.yml b/deploy/docker-compose.postgres-ci.yml new file mode 100644 index 00000000..574d1f02 --- /dev/null +++ b/deploy/docker-compose.postgres-ci.yml @@ -0,0 +1,4 @@ +services: + postgres: + ports: + - "127.0.0.1:${POSTGRES_HOST_PORT:-55432}:5432" diff --git a/deploy/docker-compose.postgres.yml b/deploy/docker-compose.postgres.yml new file mode 100644 index 00000000..7d26d900 --- /dev/null +++ b/deploy/docker-compose.postgres.yml @@ -0,0 +1,59 @@ +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + shm_size: 128mb + env_file: + - ${DATABASE_ENV_FILE:-../.env.staging.database} + command: + - postgres + - -c + - max_connections=30 + - -c + - shared_buffers=256MB + - -c + - effective_cache_size=1GB + - -c + - work_mem=4MB + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/001-bootstrap-roles.sh:/docker-entrypoint-initdb.d/001-bootstrap-roles.sh:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + networks: [app] + + migrator: + image: ${WEB_IMAGE:-jyotisha-web:local} + profiles: ["migration"] + restart: "no" + env_file: + - ${DATABASE_ENV_FILE:-../.env.staging.database} + working_dir: /app/frontend + command: ["npm", "run", "db:migrate"] + depends_on: + postgres: + condition: service_healthy + networks: [app] + + migration-checker: + image: ${WEB_IMAGE:-jyotisha-web:local} + profiles: ["migration-check"] + restart: "no" + env_file: + - ${DATABASE_ENV_FILE:-../.env.staging.database} + working_dir: /app/frontend + command: ["npm", "run", "db:migrate:check"] + depends_on: + postgres: + condition: service_healthy + networks: [app] + +volumes: + postgres_data: + +networks: + app: diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index caa615f6..9e660a16 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -1,10 +1,12 @@ services: api: + image: ${API_IMAGE:-jyotisha-api:local} build: context: .. dockerfile: deploy/railway-api.Dockerfile restart: unless-stopped - env_file: ../.env.production + env_file: + - ${APP_ENV_FILE:-../.env.production} environment: PORT: 5200 JYOTISH_ALLOWED_HOSTS: localhost,127.0.0.1,::1,api @@ -17,6 +19,7 @@ services: retries: 5 web: + image: ${WEB_IMAGE:-jyotisha-web:local} build: context: .. dockerfile: deploy/railway-web.Dockerfile @@ -24,7 +27,8 @@ services: NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} restart: unless-stopped - env_file: ../.env.production + env_file: + - ${APP_ENV_FILE:-../.env.production} environment: GITHUB_SHA: ${GITHUB_SHA} PORT: 3000 @@ -45,7 +49,7 @@ services: - "443:443" - "443:443/udp" volumes: - - ./Caddyfile:/etc/caddy/Caddyfile:ro + - ${CADDYFILE_PATH:-./Caddyfile}:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config depends_on: diff --git a/deploy/postgres/001-bootstrap-roles.sh b/deploy/postgres/001-bootstrap-roles.sh new file mode 100755 index 00000000..cee4a948 --- /dev/null +++ b/deploy/postgres/001-bootstrap-roles.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=( + POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD + SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD + ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD BACKUP_READER_PASSWORD +) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required database bootstrap variable is missing: $key" >&2 + exit 1 + fi +done + +psql --set ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + --set database_name="$POSTGRES_DB" \ + --set schema_owner_password="$SCHEMA_OWNER_PASSWORD" \ + --set identity_runtime_password="$IDENTITY_RUNTIME_PASSWORD" \ + --set app_runtime_password="$APP_RUNTIME_PASSWORD" \ + --set admin_runtime_password="$ADMIN_RUNTIME_PASSWORD" \ + --set migration_runner_password="$MIGRATION_RUNNER_PASSWORD" \ + --set backup_reader_password="$BACKUP_READER_PASSWORD" <<'SQL' +SELECT format( + 'CREATE ROLE schema_owner WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'schema_owner_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'schema_owner' +) \gexec +SELECT format( + 'CREATE ROLE identity_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'identity_runtime_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'identity_runtime' +) \gexec +SELECT format( + 'CREATE ROLE app_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'app_runtime_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'app_runtime' +) \gexec +SELECT format( + 'CREATE ROLE admin_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'admin_runtime_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'admin_runtime' +) \gexec +SELECT format( + 'CREATE ROLE migration_runner WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'migration_runner_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'migration_runner' +) \gexec +SELECT format( + 'CREATE ROLE backup_reader WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'backup_reader_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'backup_reader' +) \gexec + +SELECT format( + 'GRANT CONNECT, CREATE ON DATABASE %I TO schema_owner', + :'database_name' +) \gexec +SELECT format( + 'GRANT CONNECT ON DATABASE %I TO identity_runtime, app_runtime, admin_runtime, migration_runner, backup_reader', + :'database_name' +) \gexec + +ALTER SCHEMA public OWNER TO schema_owner; +REVOKE ALL ON SCHEMA public FROM PUBLIC; +SQL diff --git a/deploy/railway-web.Dockerfile b/deploy/railway-web.Dockerfile index 0775a192..5aa2ca5e 100644 --- a/deploy/railway-web.Dockerfile +++ b/deploy/railway-web.Dockerfile @@ -7,6 +7,8 @@ RUN npm ci COPY frontend/src ./src COPY frontend/public ./public COPY frontend/next.config.ts frontend/postcss.config.mjs frontend/tsconfig.json ./ +COPY frontend/scripts ./scripts +COPY frontend/db ./db ARG NEXT_PUBLIC_SUPABASE_URL ARG NEXT_PUBLIC_SUPABASE_ANON_KEY diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh new file mode 100755 index 00000000..153d658a --- /dev/null +++ b/deploy/run-staging-deploy.sh @@ -0,0 +1,217 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=( + INCOMING_PATH DEPLOY_PATH API_IMAGE WEB_IMAGE DEPLOY_SHA + EXPECTED_PREVIOUS_SHA ALLOW_ROLLBACK DOCKER_CONFIG STAGING_URL +) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required staging deployment input is missing: $key" >&2 + exit 1 + fi +done + +sha_pattern='^[0-9a-f]{40}$' +digest_pattern='^ghcr\.io/jesse-ux/jyotisha-(api|web)@sha256:[0-9a-f]{64}$' +image_id_pattern='^sha256:[0-9a-f]{64}$' +if [[ ! "$DEPLOY_SHA" =~ $sha_pattern ]] || + [[ ! "$API_IMAGE" =~ $digest_pattern ]] || + [[ ! "$WEB_IMAGE" =~ $digest_pattern ]]; then + echo "unsafe staging image identity" >&2 + exit 1 +fi +if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then + echo "invalid rollback authorization" >&2 + exit 1 +fi +case "$INCOMING_PATH" in + "$DEPLOY_PATH"/.incoming/*) ;; + *) echo "unsafe incoming staging path" >&2; exit 1 ;; +esac + +state_directory="$DEPLOY_PATH/.state" +install -d -m 700 "$state_directory" +exec 9>"$state_directory/mutation.lock" +flock -n 9 || { + echo "another staging mutation holds the host lock" >&2 + exit 75 +} + +current_sha="not-deployed" +if [ -f "$state_directory/deployed-revision" ]; then + current_sha="$(<"$state_directory/deployed-revision")" +else + existing_web="$(docker ps -aq \ + --filter 'label=com.docker.compose.project=jyotisha-staging' \ + --filter 'label=com.docker.compose.service=web' | head -n 1)" + if [ -n "$existing_web" ]; then + discovered_sha="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \ + "$existing_web" | sed -n 's/^GITHUB_SHA=//p' | head -n 1)" + if [ -n "$discovered_sha" ]; then current_sha="$discovered_sha"; fi + fi +fi +if [ "$current_sha" != "not-deployed" ] && [[ ! "$current_sha" =~ $sha_pattern ]]; then + echo "invalid deployed staging revision state" >&2 + exit 1 +fi +if [ "$current_sha" != "$EXPECTED_PREVIOUS_SHA" ]; then + echo "staging revision changed while this deployment was waiting" >&2 + exit 1 +fi +if [ "$ALLOW_ROLLBACK" = "false" ] && + [ "$current_sha" != "not-deployed" ] && + [ "$current_sha" != "$DEPLOY_SHA" ] && + [ "${FORWARD_REVISION_VERIFIED:-false}" != "true" ]; then + echo "forward staging revision was not verified" >&2 + exit 1 +fi + +container_id() { + docker ps -aq \ + --filter 'label=com.docker.compose.project=jyotisha-staging' \ + --filter "label=com.docker.compose.service=$1" | head -n 1 +} + +repo_digest_for_container() { + local service="$1" + local repository="$2" + local id image_id + id="$(container_id "$service")" + [ -n "$id" ] || return 0 + image_id="$(docker inspect --format '{{.Image}}' "$id")" + docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$image_id" | + awk -v prefix="$repository@sha256:" 'index($0, prefix) == 1 { print; exit }' +} + +previous_api_image="$(repo_digest_for_container api ghcr.io/jesse-ux/jyotisha-api)" +previous_web_image="$(repo_digest_for_container web ghcr.io/jesse-ux/jyotisha-web)" +previous_api_id="" +previous_web_id="" +if [ -n "$(container_id api)" ]; then + previous_api_id="$(docker inspect --format '{{.Image}}' "$(container_id api)")" +fi +if [ -n "$(container_id web)" ]; then + previous_web_id="$(docker inspect --format '{{.Image}}' "$(container_id web)")" +fi + +rollback_image() { + local digest_ref="$1" + local image_id="$2" + if [[ "$digest_ref" =~ $digest_pattern ]]; then + printf '%s' "$digest_ref" + elif [[ "$image_id" =~ $image_id_pattern ]]; then + printf '%s' "$image_id" + fi +} + +previous_api_target="$(rollback_image "$previous_api_image" "$previous_api_id")" +previous_web_target="$(rollback_image "$previous_web_image" "$previous_web_id")" + +bash "$INCOMING_PATH/deploy/sync-staging-tree.sh" \ + "$INCOMING_PATH" "$DEPLOY_PATH" + +cd "$DEPLOY_PATH" +bash deploy/validate-staging-env.sh \ + .env.staging staging.jyotisha.chat deploy/Caddyfile.staging +bash deploy/validate-staging-database-env.sh .env.staging.database + +compose=( + docker compose -p jyotisha-staging --env-file .env.staging + -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml +) +export APP_ENV_FILE='../.env.staging' +export DATABASE_ENV_FILE='../.env.staging.database' +export CADDYFILE_PATH='./Caddyfile.staging' +export SITE_ADDRESS='https://staging.jyotisha.chat' +export GITHUB_SHA="$DEPLOY_SHA" + +"${compose[@]}" config --quiet +"${compose[@]}" pull api web +"${compose[@]}" up -d --no-build --pull never --wait postgres + +set +e +"${compose[@]}" --profile migration-check run --rm migration-checker +check_status=$? +set -e +if [ "$check_status" -eq 3 ]; then + echo "pending migrations: run Migrate Staging Database for $DEPLOY_SHA" >&2 + exit 3 +fi +if [ "$check_status" -ne 0 ]; then + echo "staging migration check failed safely" >&2 + exit "$check_status" +fi + +switched=false +rollback() { + local status=$? + if [ "$switched" = "true" ] && + [ -n "$previous_api_target" ] && + [ -n "$previous_web_target" ] && + [[ "$current_sha" =~ $sha_pattern ]]; then + echo "staging verification failed; restoring prior application images" >&2 + API_IMAGE="$previous_api_target" WEB_IMAGE="$previous_web_target" \ + GITHUB_SHA="$current_sha" \ + "${compose[@]}" up -d --no-build --remove-orphans api web caddy || true + fi + exit "$status" +} +trap rollback ERR + +switched=true +"${compose[@]}" up -d --no-build --remove-orphans + +verify_container_image() { + local service="$1" + local expected_ref="$2" + local id expected_id running_id repo_digests + id="$(container_id "$service")" + [ -n "$id" ] + expected_id="$(docker image inspect --format '{{.Id}}' "$expected_ref")" + running_id="$(docker inspect --format '{{.Image}}' "$id")" + [ "$running_id" = "$expected_id" ] + repo_digests="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$expected_id")" + grep -Fqx "$expected_ref" <<<"$repo_digests" +} +verify_container_image api "$API_IMAGE" +verify_container_image web "$WEB_IMAGE" + +"${compose[@]}" exec -T \ + -e EXPECTED_SHA="$DEPLOY_SHA" -e STAGING_URL="$STAGING_URL" \ + web node --input-type=module <<'NODE' +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +let login; +for (let attempt = 0; attempt < 12; attempt += 1) { + try { + login = await fetch(`${process.env.STAGING_URL}/login`); + if (login.ok) break; + } catch {} + await delay(5_000); +} +if (!login?.ok) process.exit(1); +const account = await fetch(`${process.env.STAGING_URL}/api/account`); +if (account.status !== 401) process.exit(1); +const publicHealth = await fetch(`${process.env.STAGING_URL}/api/health`); +const publicBody = await publicHealth.json(); +if (!publicHealth.ok || publicBody.deployment?.gitCommit !== process.env.EXPECTED_SHA) { + process.exit(1); +} +const privateHealth = await fetch("http://api:5200/api/health"); +const privateBody = await privateHealth.json(); +if (!privateHealth.ok || privateBody.status !== "ok" || privateBody.swisseph_available !== true) { + process.exit(1); +} +NODE + +revision_file="$state_directory/deployed-revision.tmp.$$" +printf '%s\n' "$DEPLOY_SHA" >"$revision_file" +chmod 600 "$revision_file" +mv -f "$revision_file" "$state_directory/deployed-revision" +trap - ERR + +printf 'previous_sha=%s\nprevious_api_image=%s\nprevious_api_id=%s\n' \ + "$current_sha" "${previous_api_image:-not-deployed}" "${previous_api_id:-not-deployed}" +printf 'previous_web_image=%s\nprevious_web_id=%s\nverified_sha=%s\n' \ + "${previous_web_image:-not-deployed}" "${previous_web_id:-not-deployed}" "$DEPLOY_SHA" diff --git a/deploy/run-staging-migration.sh b/deploy/run-staging-migration.sh new file mode 100755 index 00000000..cc0b9ee8 --- /dev/null +++ b/deploy/run-staging-migration.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=( + INCOMING_PATH DEPLOY_PATH WEB_IMAGE DEPLOY_SHA EXPECTED_PREVIOUS_SHA + DOCKER_CONFIG +) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required staging migration input is missing: $key" >&2 + exit 1 + fi +done + +[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "unsafe staging migration revision" >&2 + exit 1 +} +[[ "$WEB_IMAGE" =~ ^ghcr\.io/jesse-ux/jyotisha-web@sha256:[0-9a-f]{64}$ ]] || { + echo "unsafe staging migration image" >&2 + exit 1 +} +case "$INCOMING_PATH" in + "$DEPLOY_PATH"/.incoming/*) ;; + *) echo "unsafe incoming staging path" >&2; exit 1 ;; +esac + +state_directory="$DEPLOY_PATH/.state" +install -d -m 700 "$state_directory" +exec 9>"$state_directory/mutation.lock" +flock -n 9 || { + echo "another staging mutation holds the host lock" >&2 + exit 75 +} + +current_sha="not-deployed" +if [ -f "$state_directory/deployed-revision" ]; then + current_sha="$(<"$state_directory/deployed-revision")" +else + existing_web="$(docker ps -aq \ + --filter 'label=com.docker.compose.project=jyotisha-staging' \ + --filter 'label=com.docker.compose.service=web' | head -n 1)" + if [ -n "$existing_web" ]; then + discovered_sha="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \ + "$existing_web" | sed -n 's/^GITHUB_SHA=//p' | head -n 1)" + if [ -n "$discovered_sha" ]; then current_sha="$discovered_sha"; fi + fi +fi +if [ "$current_sha" != "not-deployed" ] && [[ ! "$current_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "invalid deployed staging revision state" >&2 + exit 1 +fi +[ "$current_sha" = "$EXPECTED_PREVIOUS_SHA" ] || { + echo "staging revision changed while this migration was waiting" >&2 + exit 1 +} +[ "$current_sha" = "not-deployed" ] || + [ "$current_sha" = "$DEPLOY_SHA" ] || + [ "${FORWARD_REVISION_VERIFIED:-false}" = "true" ] || { + echo "forward staging revision was not verified" >&2 + exit 1 + } + +bash "$INCOMING_PATH/deploy/sync-staging-tree.sh" \ + "$INCOMING_PATH" "$DEPLOY_PATH" + +cd "$DEPLOY_PATH" +bash deploy/validate-staging-env.sh \ + .env.staging staging.jyotisha.chat deploy/Caddyfile.staging +bash deploy/validate-staging-database-env.sh .env.staging.database + +export DATABASE_ENV_FILE='../.env.staging.database' +compose=(docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml) +docker pull "$WEB_IMAGE" +"${compose[@]}" up -d --no-build --pull never --wait postgres +"${compose[@]}" --profile migration run --rm migrator +"${compose[@]}" exec -T postgres psql -U postgres -d jyotisha -Atc \ + 'select filename from migration.schema_migrations order by filename' diff --git a/deploy/sync-staging-tree.sh b/deploy/sync-staging-tree.sh new file mode 100755 index 00000000..005766fa --- /dev/null +++ b/deploy/sync-staging-tree.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ] || [ ! -d "$1" ] || [ ! -d "$2" ]; then + echo "usage: sync-staging-tree.sh SOURCE_DIRECTORY DESTINATION_DIRECTORY" >&2 + exit 1 +fi + +rsync -az --delete \ + --exclude='/.git/' \ + --exclude='/.env*' \ + --exclude='/.docker/' \ + --exclude='/backups/' \ + --exclude='/.state/' \ + --exclude='/.incoming/' \ + --exclude='/frontend/node_modules/' \ + --exclude='/frontend/.next/' \ + "$1/" "$2/" diff --git a/deploy/validate-staging-database-env.sh b/deploy/validate-staging-database-env.sh new file mode 100755 index 00000000..2949219c --- /dev/null +++ b/deploy/validate-staging-database-env.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +ENV_FILE="${1:-.env.staging.database}" + +if [ ! -e "$ENV_FILE" ]; then + echo "staging database environment file is missing" >&2 + exit 1 +fi + +if [ -L "$ENV_FILE" ]; then + echo "staging database environment file must not be a symlink" >&2 + exit 1 +fi + +if [ ! -f "$ENV_FILE" ]; then + echo "staging database environment path must be a regular file" >&2 + exit 1 +fi + +if MODE="$(stat -c '%a' "$ENV_FILE" 2>/dev/null)"; then + : +else + MODE="$(stat -f '%Lp' "$ENV_FILE")" +fi + +if [ "$MODE" != "600" ]; then + echo "staging database environment file must have mode 0600" >&2 + exit 1 +fi + +if OWNER="$(stat -c '%u' "$ENV_FILE" 2>/dev/null)"; then + : +else + OWNER="$(stat -f '%u' "$ENV_FILE")" +fi + +if [ "$OWNER" != "$(id -u)" ]; then + echo "staging database environment file must be owned by the current user" >&2 + exit 1 +fi + +definition_count() { + local key="$1" + grep -Ec "^[[:space:]]*(export[[:space:]]+)?${key}([[:space:]]*=|[[:space:]]*$)" "$ENV_FILE" || true +} + +environment_value() { + local key="$1" + sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*(.*)$/\\2/p" "$ENV_FILE" +} + +is_safe_literal() { + local value="$1" + local inner + + # Required values are literal single-line values: use an unquoted token or + # matching non-empty quotes. Dotenv interpolation, comments, and malformed + # quoting are rejected rather than evaluated, so generate secrets without $. + if [ -z "$value" ] || [[ "$value" == *'$'* ]]; then + return 1 + fi + + case "$value" in + \"*\") + inner="${value:1}" + inner="${inner%?}" + [ -n "$inner" ] && [[ "$inner" != *'"'* ]] + ;; + \'*\') + inner="${value:1}" + inner="${inner%?}" + [ -n "$inner" ] && [[ "$inner" != *"'"* ]] + ;; + *\"*|*\'*) + return 1 + ;; + *[[:space:]]*|*\#*) + return 1 + ;; + esac +} + +require_once_non_empty() { + local key="$1" + local count + local value + count="$(definition_count "$key")" + value="$(environment_value "$key")" + if [ "$count" -ne 1 ] || ! is_safe_literal "$value"; then + echo "required staging database literal is missing, duplicated, or ambiguous: $key" >&2 + exit 1 + fi +} + +required=( + POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD + SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD + ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD BACKUP_READER_PASSWORD + STAGING_BACKUP_ENCRYPTION_KEY SCHEMA_DATABASE_URL +) +for key in "${required[@]}"; do + require_once_non_empty "$key" +done + +if [ "$(environment_value POSTGRES_DB)" != "jyotisha" ]; then + echo "invalid staging database selector: POSTGRES_DB" >&2 + exit 1 +fi + +if [ "$(environment_value POSTGRES_USER)" != "postgres" ]; then + echo "invalid staging database selector: POSTGRES_USER" >&2 + exit 1 +fi + +if ! [[ "$(environment_value SCHEMA_DATABASE_URL)" =~ ^postgresql://schema_owner:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid staging database selector: SCHEMA_DATABASE_URL" >&2 + exit 1 +fi + +echo "staging database environment validated" diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh new file mode 100755 index 00000000..0c82f578 --- /dev/null +++ b/deploy/validate-staging-env.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env.staging}" + +if [ ! -f "$ENV_FILE" ]; then + echo "staging environment file is missing: $ENV_FILE" >&2 + exit 1 +fi + +if MODE="$(stat -c '%a' "$ENV_FILE" 2>/dev/null)"; then + : +else + MODE="$(stat -f '%Lp' "$ENV_FILE")" +fi + +if [ "$MODE" != "600" ]; then + echo "staging environment file must have mode 0600" >&2 + exit 1 +fi + +require_selector() { + local key="$1" + local expected="$2" + local count + local definition_pattern + + definition_pattern="^[[:space:]]*(export[[:space:]]+)?${key}([[:space:]]*=|[[:space:]]*$)" + count="$(grep -Ec "$definition_pattern" "$ENV_FILE" || true)" + if [ "$count" -ne 1 ] || ! grep -Fqx "${key}=${expected}" "$ENV_FILE"; then + echo "invalid staging selector: $key" >&2 + exit 1 + fi +} + +require_selector APP_ENV_FILE ../.env.staging +require_selector CADDYFILE_PATH ./Caddyfile.staging +require_selector SITE_ADDRESS https://staging.jyotisha.chat + +echo "staging environment selectors: valid" diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index b443427d..d7b96c7e 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -125,7 +125,7 @@ Prevention: add a new domain only after its public cases satisfy the same source ## Fragment Sweep Command Set ## ERR-084 | Pre-work fragment test assumes zero candidates despite current audited candidates | active 2026-07-19 -`scripts/pre_work_check.py` reports `fragment_audit.candidate_count=2`, while `tests/test_preflight_fragment_scan.py` requires exactly zero. The pre-work command therefore cannot be reported green until the two candidates are classified or the test is updated to validate the reviewed state rather than a hard-coded count. +`scripts/pre_work_check.py` reported `fragment_audit.candidate_count=2` on 2026-07-19 and reports `3` in the 2026-07-20 run, while `tests/test_preflight_fragment_scan.py` requires exactly zero. The pre-work command therefore cannot be reported green until the candidates are classified or the test is updated to validate the reviewed state rather than a hard-coded count. Prevention: retain candidate identity and classification in the sweep artifact; do not mask candidates or weaken the pre-work result. diff --git a/docs/superpowers/plans/2026-07-20-postgres-quality-gate-foundation.md b/docs/superpowers/plans/2026-07-20-postgres-quality-gate-foundation.md new file mode 100644 index 00000000..ded1bba7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-postgres-quality-gate-foundation.md @@ -0,0 +1,1044 @@ +# PostgreSQL and Backend Quality-Gate Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the self-hosted PostgreSQL staging foundation, least-privilege roles, reviewed SQL migration runner, automatic backend quality gate, immutable GHCR images, and exact-SHA staging deploy path without moving authentication or business traffic off Supabase. + +**Architecture:** PostgreSQL 17 runs on the private Compose network of the Hong Kong staging VPS. A separate deployment-user-owned database env file supplies bootstrap and schema credentials only to PostgreSQL and an opt-in migrator; normal web/API containers never receive them. Reviewed plain SQL is the schema source of truth, while `pg` and Drizzle provide the future runtime seam. PRs and `staging` pushes run database/backend/frontend/configuration tests; successful `staging` pushes publish web/API images and a run-bound SHA-to-digest manifest, and staging deploys only those exact digests. Migrations remain a separate manual workflow. + +**Tech Stack:** PostgreSQL 17 Alpine, Docker Compose, Node.js 22, Next.js 16, TypeScript, `pg`, Drizzle ORM, Python 3.12, GitHub Actions, GHCR, Bash, OpenSSL. + +## Global Constraints + +- Scope is only Milestone 1 of `docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md`. +- Prerequisite: merge `codex/staging-deployment-automation` commit `801666a6c71b8efc220afa4248f42c5c776ba9e6` into `main`, then create the implementation worktree from that updated `main`. +- Before starting, these prerequisite files must exist: `.github/workflows/deploy-staging.yml`, `deploy/Caddyfile.staging`, and `deploy/validate-staging-env.sh`. +- Supabase remains source of truth. Do not add Better Auth, identity cutover, admin UI, dual writes, or business-table migration in this milestone. +- Staging PostgreSQL has no published port. Only the CI overlay may bind a loopback port. +- `.env.staging.database` is server-side only, mode `0600`, and excluded from Git/rsync. It contains bootstrap and migration credentials. `.env.staging` must not contain them. +- Normal web/API containers never receive `SCHEMA_DATABASE_URL`. +- App deployment never runs schema migration. Migration is manual and separately serialized. +- Before changing app containers, staging deploy runs the exact SHA image in read-only migration-check mode. No pending migration means automatic continuation. Pending or checksum-drifted migration stops before app changes; a successful manual migration dispatches staging deploy again for the same full SHA. +- Production defaults remain manual-only and unchanged. +- Staging publication uses full Git SHA tags for discovery, but deployment is + authorized and pinned by the build outputs' `sha256` manifest digests. Never + deploy a mutable tag such as `latest`, or treat a tag alone as image identity. +- The `main` workflow revision is the trusted deployment controller. Target and + rollback SHAs must already be ancestors of reviewed `main`; their code is + represented by the digest-pinned images, but their validators and remote + orchestration scripts are never executed with staging Environment privileges. +- Finish each task with the focused commit shown. + +## Planned Files + +```text +.github/workflows/backend-quality-gate.yml +.github/workflows/deploy-staging.yml +.github/workflows/migrate-staging-database.yml +deploy/backup-staging-postgres.sh +deploy/docker-compose.postgres-ci.yml +deploy/docker-compose.postgres.yml +deploy/docker-compose.server.yml +deploy/postgres/001-bootstrap-roles.sh +deploy/validate-staging-database-env.sh +frontend/db/migrations/20260720000100_backend_foundation.sql +frontend/scripts/db-migrate.mjs +frontend/src/lib/db/client.ts +frontend/src/lib/db/config.ts +frontend/tests/database-backup.test.ts +frontend/tests/database-foundation.test.ts +frontend/tests/database-topology.test.ts +frontend/tests/helpers/postgres-fixture.ts +frontend/tests/staging-backend-workflows.test.ts +``` + +--- + +### Task 1: Private PostgreSQL topology and roles + +**Files:** + +- Create: `deploy/docker-compose.postgres.yml` +- Create: `deploy/docker-compose.postgres-ci.yml` +- Create: `deploy/postgres/001-bootstrap-roles.sh` +- Create: `deploy/validate-staging-database-env.sh` +- Create: `frontend/tests/helpers/postgres-fixture.ts` +- Create: `frontend/tests/database-topology.test.ts` +- Modify: `frontend/package.json` + +- [ ] **Step 1: Verify prerequisite** + +```bash +git merge-base --is-ancestor 801666a6c71b8efc220afa4248f42c5c776ba9e6 HEAD +test -f .github/workflows/deploy-staging.yml +test -f deploy/Caddyfile.staging +test -x deploy/validate-staging-env.sh +``` + +Expected: all exit `0`. Otherwise stop; do not duplicate the prerequisite branch. + +- [ ] **Step 2: Write the failing topology test** + +Add `frontend/tests/helpers/postgres-fixture.ts` exporting: + +```ts +export type PostgresFixture = { + projectName: string; + databaseEnvFile: string; + hostPort: number; + connectionUrl(role: string, password: string): string; + psql(sql: string): string; + stop(): void; +}; +export function startPostgresFixture(): PostgresFixture; +``` + +It creates a mode-`0600` temp env, chooses an unused port from `55432..55531`, starts Compose with the two files below and `--wait postgres`, and always runs `down -v --remove-orphans` in `stop()`. Use only these deterministic test values: + +```text +POSTGRES_DB=jyotisha +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres-test-password +SCHEMA_OWNER_PASSWORD=schema-owner-test-password +IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password +APP_RUNTIME_PASSWORD=app-runtime-test-password +ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password +MIGRATION_RUNNER_PASSWORD=migration-runner-test-password +BACKUP_READER_PASSWORD=backup-reader-test-password +STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password +SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-test-password@postgres:5432/jyotisha +``` + +Add `frontend/tests/database-topology.test.ts`: + +```ts +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { startPostgresFixture } from "./helpers/postgres-fixture"; + +test("staging postgres is private and CI binds loopback only", () => { + const staging = readFileSync("../deploy/docker-compose.postgres.yml", "utf8"); + const ci = readFileSync("../deploy/docker-compose.postgres-ci.yml", "utf8"); + assert.match(staging, /image:\s*postgres:17-alpine/); + assert.doesNotMatch(staging, /^\s+ports:/m); + assert.match(ci, /127\.0\.0\.1:\$\{POSTGRES_HOST_PORT:-55432\}:5432/); +}); + +test("database roles have no cluster privileges", () => { + const fixture = startPostgresFixture(); + try { + assert.equal( + fixture.psql(` + select rolname || ':' || rolsuper || ':' || rolcreatedb || ':' || + rolcreaterole || ':' || rolbypassrls + from pg_roles + where rolname in ('schema_owner','identity_runtime','app_runtime', + 'admin_runtime','migration_runner','backup_reader') + order by rolname + `), + [ + "admin_runtime:f:f:f:f", + "app_runtime:f:f:f:f", + "backup_reader:f:f:f:f", + "identity_runtime:f:f:f:f", + "migration_runner:f:f:f:f", + "schema_owner:f:f:f:f", + ].join("\n"), + ); + } finally { + fixture.stop(); + } +}); +``` + +Add: + +```json +"test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts" +``` + +- [ ] **Step 3: Confirm red** + +```bash +cd frontend && npm run test:db +``` + +Expected: FAIL because the Compose topology does not exist. + +- [ ] **Step 4: Add staging and CI Compose files** + +Create `deploy/docker-compose.postgres.yml`: + +```yaml +services: + postgres: + image: postgres:17-alpine + restart: unless-stopped + shm_size: 128mb + env_file: + - ${DATABASE_ENV_FILE:-../.env.staging.database} + command: + - postgres + - -c + - max_connections=30 + - -c + - shared_buffers=256MB + - -c + - effective_cache_size=1GB + - -c + - work_mem=4MB + volumes: + - postgres_data:/var/lib/postgresql/data + - ./postgres/001-bootstrap-roles.sh:/docker-entrypoint-initdb.d/001-bootstrap-roles.sh:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + networks: [app] + + migrator: + image: ${WEB_IMAGE:-jyotisha-web:local} + profiles: ["migration"] + restart: "no" + env_file: + - ${DATABASE_ENV_FILE:-../.env.staging.database} + working_dir: /app/frontend + command: ["npm", "run", "db:migrate"] + depends_on: + postgres: + condition: service_healthy + networks: [app] + + migration-checker: + image: ${WEB_IMAGE:-jyotisha-web:local} + profiles: ["migration-check"] + restart: "no" + env_file: + - ${DATABASE_ENV_FILE:-../.env.staging.database} + working_dir: /app/frontend + command: ["npm", "run", "db:migrate:check"] + depends_on: + postgres: + condition: service_healthy + networks: [app] + +volumes: + postgres_data: + +networks: + app: +``` + +Create `deploy/docker-compose.postgres-ci.yml`: + +```yaml +services: + postgres: + ports: + - "127.0.0.1:${POSTGRES_HOST_PORT:-55432}:5432" +``` + +- [ ] **Step 5: Implement idempotent role bootstrap** + +Create executable `deploy/postgres/001-bootstrap-roles.sh`: + +```bash +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=( + POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD + SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD + ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD BACKUP_READER_PASSWORD +) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required database bootstrap variable is missing: $key" >&2 + exit 1 + fi +done + +psql --set ON_ERROR_STOP=1 \ + --username "$POSTGRES_USER" \ + --dbname "$POSTGRES_DB" \ + --set database_name="$POSTGRES_DB" \ + --set schema_owner_password="$SCHEMA_OWNER_PASSWORD" \ + --set identity_runtime_password="$IDENTITY_RUNTIME_PASSWORD" \ + --set app_runtime_password="$APP_RUNTIME_PASSWORD" \ + --set admin_runtime_password="$ADMIN_RUNTIME_PASSWORD" \ + --set migration_runner_password="$MIGRATION_RUNNER_PASSWORD" \ + --set backup_reader_password="$BACKUP_READER_PASSWORD" <<'SQL' +SELECT format( + 'CREATE ROLE schema_owner WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'schema_owner_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'schema_owner' +) \gexec +SELECT format( + 'CREATE ROLE identity_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'identity_runtime_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'identity_runtime' +) \gexec +SELECT format( + 'CREATE ROLE app_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'app_runtime_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'app_runtime' +) \gexec +SELECT format( + 'CREATE ROLE admin_runtime WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'admin_runtime_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'admin_runtime' +) \gexec +SELECT format( + 'CREATE ROLE migration_runner WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'migration_runner_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'migration_runner' +) \gexec +SELECT format( + 'CREATE ROLE backup_reader WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', + :'backup_reader_password' +) WHERE NOT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'backup_reader' +) \gexec + +SELECT format( + 'GRANT CONNECT, CREATE ON DATABASE %I TO schema_owner', + :'database_name' +) \gexec +SELECT format( + 'GRANT CONNECT ON DATABASE %I TO identity_runtime, app_runtime, admin_runtime, migration_runner, backup_reader', + :'database_name' +) \gexec +SQL +``` + +Run `chmod +x deploy/postgres/001-bootstrap-roles.sh`. Do not grant role membership, `BYPASSRLS`, database ownership, or public-schema creation. + +- [ ] **Step 6: Add database-env validator** + +Create executable `deploy/validate-staging-database-env.sh`. Reuse the safe parser pattern in `validate-staging-env.sh`, never `source`/`eval`. Require exactly once and non-empty: + +```text +POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD +SCHEMA_OWNER_PASSWORD IDENTITY_RUNTIME_PASSWORD APP_RUNTIME_PASSWORD +ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD BACKUP_READER_PASSWORD +STAGING_BACKUP_ENCRYPTION_KEY SCHEMA_DATABASE_URL +``` + +Reject missing files, symlinks, foreign ownership, or mode other than `600`. Require `POSTGRES_DB=jyotisha`, `POSTGRES_USER=postgres`, and a schema URL shaped as `postgresql://schema_owner:@postgres:5432/jyotisha`. Print values never; success output is exactly `staging database environment validated`. + +- [ ] **Step 7: Verify** + +```bash +chmod +x deploy/postgres/001-bootstrap-roles.sh \ + deploy/validate-staging-database-env.sh +cd frontend && npm run test:db +``` + +Expected: PASS and fixture volumes removed. + +- [ ] **Step 8: Commit** + +```bash +git add deploy/docker-compose.postgres.yml deploy/docker-compose.postgres-ci.yml \ + deploy/postgres/001-bootstrap-roles.sh deploy/validate-staging-database-env.sh \ + frontend/tests/helpers/postgres-fixture.ts frontend/tests/database-topology.test.ts \ + frontend/package.json +git commit -m "feat: add private staging postgres topology" +``` + +--- + +### Task 2: Reviewed SQL migrations and runtime DB seam + +**Files:** + +- Create: `frontend/scripts/db-migrate.mjs` +- Create: `frontend/db/migrations/20260720000100_backend_foundation.sql` +- Create: `frontend/src/lib/db/config.ts` +- Create: `frontend/src/lib/db/client.ts` +- Create: `frontend/tests/database-foundation.test.ts` +- Modify: `frontend/package.json` +- Modify: `frontend/package-lock.json` +- Modify: `deploy/railway-web.Dockerfile` + +- [ ] **Step 1: Write failing tests** + +`frontend/tests/database-foundation.test.ts` must test: + +1. `readDatabaseUrl({}, "APP_DATABASE_URL")` throws `APP_DATABASE_URL is required`. +2. First `node scripts/db-migrate.mjs` applies one file and records a 64-character checksum. +3. Second run is a no-op with the same ledger row. +4. Applying a copied migration directory, changing one byte, then rerunning exits non-zero with `migration checksum mismatch: `. +5. `app_runtime` cannot `CREATE SCHEMA` or select `migration.schema_migrations`. +6. Test stderr/output never includes any fixture password. + +Spawn the runner with only `SCHEMA_DATABASE_URL` and optional `MIGRATIONS_DIRECTORY`. + +- [ ] **Step 2: Confirm red** + +```bash +cd frontend && npm run test:db +``` + +Expected: FAIL on missing runner/config/migration. + +- [ ] **Step 3: Install runtime packages** + +```bash +cd frontend +npm install pg drizzle-orm +npm install --save-dev @types/pg +``` + +Add `"db:migrate": "node scripts/db-migrate.mjs"` and +`"db:migrate:check": "node scripts/db-migrate.mjs --check"`. + +- [ ] **Step 4: Add typed URL config and lazy client** + +Create `frontend/src/lib/db/config.ts`: + +```ts +export type DatabaseUrlKey = + | "IDENTITY_DATABASE_URL" + | "APP_DATABASE_URL" + | "ADMIN_DATABASE_URL"; + +export function readDatabaseUrl( + env: NodeJS.ProcessEnv, + key: DatabaseUrlKey, +): string { + const value = env[key]?.trim(); + if (!value) throw new Error(`${key} is required`); + if (!value.startsWith("postgresql://")) { + throw new Error(`${key} must be a PostgreSQL URL`); + } + return value; +} +``` + +Create `frontend/src/lib/db/client.ts`: + +```ts +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +export type DomainDatabase = { pool: Pool; db: NodePgDatabase }; + +export function createDomainDatabase( + connectionString: string, + maxConnections = 5, +): DomainDatabase { + const pool = new Pool({ + connectionString, + max: maxConnections, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + application_name: "jyotisha-web", + }); + return { pool, db: drizzle(pool) }; +} +``` + +Do not instantiate a global pool yet. + +- [ ] **Step 5: Implement `db-migrate.mjs`** + +Export: + +```js +export async function runMigrations({ + connectionString, + migrationsDirectory, + logger = console, +}) {} +``` + +Required behavior: + +- Accept only sorted `/^\d{14}_[a-z0-9_]+\.sql$/` files. +- SHA-256 exact file bytes. +- One `pg.Client`. +- Acquire `select pg_advisory_lock(hashtext('jyotisha_schema_migrations'))`. +- Create `migration` owned by `schema_owner`, revoke public access, and create: + +```sql +create table if not exists migration.schema_migrations ( + filename text primary key, + checksum text not null check (length(checksum) = 64), + applied_at timestamptz not null default now() +); +``` + +- Matching row: log `already applied `. +- Changed checksum: throw `migration checksum mismatch: `. +- New file: `BEGIN`, execute file, insert ledger row, `COMMIT`; rollback on error. +- With `--check`, perform no DDL/DML: compare exact files with the existing ledger, print pending filenames only, exit `0` when current, exit `3` when any file is pending, and exit `1` on checksum drift or unsafe failure. A missing ledger means every file is pending. +- Release lock and close in `finally`. +- Never log URL, SQL, env, or driver config. +- Direct invocation defaults to `frontend/db/migrations`, requires `SCHEMA_DATABASE_URL`, prints safe filename-only errors, and exits `1`. + +- [ ] **Step 6: Add foundation migration** + +Create `frontend/db/migrations/20260720000100_backend_foundation.sql`: + +```sql +create schema if not exists identity authorization schema_owner; +create schema if not exists audit authorization schema_owner; +revoke all on schema public from public; +revoke all on schema identity from public; +revoke all on schema audit from public; +grant usage on schema identity to identity_runtime, admin_runtime; +grant usage on schema public to app_runtime, admin_runtime; +grant usage on schema audit to admin_runtime; + +alter default privileges for role schema_owner in schema identity + revoke all on tables from public; +alter default privileges for role schema_owner in schema public + revoke all on tables from public; +alter default privileges for role schema_owner in schema audit + revoke all on tables from public; +``` + +Do not create business or auth tables. The foundation grants schema discovery only; +later reviewed migrations grant access to named tables and narrow functions. Never +grant runtime roles broad default DML on future tables. + +- [ ] **Step 7: Put runner in final web image** + +Before the existing `RUN npm run build && npm prune --omit=dev` line in the single-stage `deploy/railway-web.Dockerfile`, add: + +```dockerfile +COPY frontend/scripts ./scripts +COPY frontend/db ./db +``` + +Keep `pg` in production dependencies. + +- [ ] **Step 8: Verify** + +```bash +cd frontend && npm run test:db +cd .. +docker build -f deploy/railway-web.Dockerfile \ + --build-arg NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co \ + --build-arg NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder \ + -t jyotisha-web:migration-foundation . +docker run --rm --entrypoint node jyotisha-web:migration-foundation \ + scripts/db-migrate.mjs +``` + +Expected: tests and build PASS; last command exits `1` with only `SCHEMA_DATABASE_URL is required`. + +- [ ] **Step 9: Commit** + +```bash +git add frontend/package.json frontend/package-lock.json frontend/scripts/db-migrate.mjs \ + frontend/db/migrations/20260720000100_backend_foundation.sql \ + frontend/src/lib/db/config.ts frontend/src/lib/db/client.ts \ + frontend/tests/database-foundation.test.ts deploy/railway-web.Dockerfile +git commit -m "feat: add reviewed postgres migration foundation" +``` + +--- + +### Task 3: Encrypted local staging backups + +**Files:** + +- Create: `deploy/backup-staging-postgres.sh` +- Create: `frontend/tests/database-backup.test.ts` + +- [ ] **Step 1: Write failing integration test** + +Start the Postgres fixture, run the backup script four times with deterministic `BACKUP_TIMESTAMP` values, then assert: + +- Each invocation exits `0`. +- Completed names match `jyotisha-staging-YYYYMMDDTHHMMSSZ.dump.enc`. +- No `.partial` remains and only the newest three encrypted files remain. +- Decryption with `openssl enc -d -aes-256-cbc -pbkdf2` produces a dump accepted by `pg_restore --list`. +- Output contains no fixture passwords. + +- [ ] **Step 2: Confirm red** + +```bash +cd frontend && npm run test:db +``` + +Expected: FAIL because the script is absent. + +- [ ] **Step 3: Implement backup script** + +Interface: + +```text +backup-staging-postgres.sh DATABASE_ENV_FILE BACKUP_DIRECTORY +``` + +Use `set -euo pipefail`, `set +x`, validate the env first, refuse disk usage `>=70%`, create directory `0700`, output file `0600`, and run: + +```bash +DATABASE_ENV_FILE="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +export DATABASE_ENV_FILE +docker compose -p "${COMPOSE_PROJECT_NAME:-jyotisha-staging}" \ + -f deploy/docker-compose.postgres.yml exec -T postgres \ + pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner | +openssl enc -aes-256-cbc -salt -pbkdf2 \ + -pass env:STAGING_BACKUP_ENCRYPTION_KEY > "$PARTIAL_FILE" +``` + +Atomically rename after success. Delete only older matching dumps inside the explicit backup directory, retaining three. Print path/count only. + +- [ ] **Step 4: Verify and commit** + +```bash +chmod +x deploy/backup-staging-postgres.sh +cd frontend && npm run test:db +cd .. +git add deploy/backup-staging-postgres.sh frontend/tests/database-backup.test.ts +git commit -m "feat: add encrypted staging database backups" +``` + +--- + +### Task 4: Automatic backend gate and GHCR publishing + +**Files:** + +- Create: `.github/workflows/backend-quality-gate.yml` +- Create: `frontend/tests/staging-backend-workflows.test.ts` +- Modify: `frontend/package.json` + +- [ ] **Step 1: Write failing workflow contracts** + +Assert the new workflow: + +- Is named `Staging Backend Quality Gate`. +- Runs on PR, push to `staging`, and manual dispatch. +- Cancels superseded same-ref runs. +- Runs `npm run test:db`, frontend tests/lint/build, and the exact passing Python quick gate from `.github/workflows/ci.yml`. +- Publishes only after validation and only on `staging` push. +- Gives `packages: write` only to publish. +- Pushes web/API tags with `${{ github.sha }}` and no `latest`. + +Add: + +```json +"test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts" +``` + +- [ ] **Step 2: Confirm red** + +```bash +cd frontend && npm run test:deployment +``` + +- [ ] **Step 3: Create workflow** + +Use this job structure: + +```yaml +name: Staging Backend Quality Gate +on: + pull_request: + push: + branches: [staging] + workflow_dispatch: +concurrency: + group: backend-quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true +permissions: + contents: read +jobs: + validate: + runs-on: ubuntu-latest + timeout-minutes: 30 + publish: + if: github.event_name == 'push' && github.ref == 'refs/heads/staging' + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + packages: write +``` + +Validation checks out, sets Python `3.12` and Node `22`, installs with `python -m pip install -r requirements.txt -r requirements-dev.txt` and `npm ci --prefix frontend`, then runs the exact existing Python gate: + +```bash +ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \ + tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \ + tests/test_yoga_rules_integrity.py +python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py +mkdir -p artifacts +python scripts/run_quality_gate.py \ + --profile quick --skip-yoga-logic --skip-frontend-runtime \ + 2>&1 | tee artifacts/quick-quality-gate.log +python -m build --no-isolation +``` + +It then runs `npm run test:db`, `npm test`, `npm run lint`, and `npm run build` with non-production Supabase placeholders. Upload `artifacts/quick-quality-gate.log` with `if: always()`. + +Publish logs into GHCR using `GITHUB_TOKEN`, then use `docker/build-push-action@v6`. The API build uses repository context `.` with `file: deploy/railway-api.Dockerfile`: + +```yaml +tags: ghcr.io/jesse-ux/jyotisha-api:${{ github.sha }} +``` + +The web build uses repository context `.` with `file: deploy/railway-web.Dockerfile`: + +```yaml +tags: ghcr.io/jesse-ux/jyotisha-web:${{ github.sha }} +build-args: | + NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co + NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder +``` + +- [ ] **Step 4: Verify and commit** + +```bash +cd frontend +npm run test:deployment +npm run test:db +npm test +npm run lint +NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co \ +NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder npm run build +cd .. +ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \ + tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \ + tests/test_yoga_rules_integrity.py +python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py +python scripts/run_quality_gate.py \ + --profile quick --skip-yoga-logic --skip-frontend-runtime +python -m build --no-isolation +git add .github/workflows/backend-quality-gate.yml \ + frontend/tests/staging-backend-workflows.test.ts frontend/package.json +git commit -m "ci: add automatic backend quality gate" +``` + +Expected: all commands PASS. + +--- + +### Task 5: Exact-image staging deployment + +**Files:** + +- Modify: `deploy/docker-compose.server.yml` +- Modify: `.github/workflows/deploy-staging.yml` +- Modify: `frontend/tests/health-deployment.test.ts` +- Modify: `frontend/tests/staging-backend-workflows.test.ts` + +- [ ] **Step 1: Add failing contracts** + +Assert: + +- Base Compose has `image: ${API_IMAGE:-jyotisha-api:local}` and `image: ${WEB_IMAGE:-jyotisha-web:local}`, while retaining both `build:` blocks. +- Staging listens to successful `Staging Backend Quality Gate`; manual validation queries `backend-quality-gate.yml` for exact SHA. +- Every staging Compose invocation uses server and Postgres files plus explicit app/database env, Caddyfile, hostname, API image, and web image. +- Workflow logs into GHCR, pulls, and runs `up -d --no-build`. +- It never invokes the applying `db:migrate` command, `migrator` service, or `--profile migration`. +- It runs the exact web image through `migration-checker`/`db:migrate:check` before changing any app container. +- Pending migrations stop before `api`, `web`, or `caddy` changes and print the manual workflow name plus exact SHA. +- Rollback uses recorded prior digest references, image IDs, and SHA. + +- [ ] **Step 2: Confirm red** + +```bash +cd frontend && npm run test:deployment +``` + +- [ ] **Step 3: Add image indirection** + +Keep build definitions and add: + +```yaml +services: + api: + image: ${API_IMAGE:-jyotisha-api:local} + web: + image: ${WEB_IMAGE:-jyotisha-web:local} +``` + +No-selector production invocations must still build locally. + +- [ ] **Step 4: Update workflow** + +- Listen to `["Staging Backend Quality Gate"]`. +- Manual API lookup uses `/actions/workflows/backend-quality-gate.yml/runs`. +- Add `packages: read`. +- Pin every remote Compose call with: + +```text +APP_ENV_FILE=../.env.staging +DATABASE_ENV_FILE=../.env.staging.database +CADDYFILE_PATH=./Caddyfile.staging +SITE_ADDRESS=staging.jyotisha.chat +API_IMAGE=ghcr.io/jesse-ux/jyotisha-api@sha256: +WEB_IMAGE=ghcr.io/jesse-ux/jyotisha-web@sha256: +``` + +- Validate both env files and Compose config. +- Send GHCR token through `docker login --password-stdin`; never save it in either env file. +- Pull `api web postgres`, start/wait for PostgreSQL, then run the exact web image through `--profile migration-check run --rm migration-checker`. +- Continue to `up -d --no-build --remove-orphans` only after check exit `0`; treat exit `3` as a safe stop with no application changes. +- Download and validate the successful gate run's SHA-to-digest manifest. Record + prior container digest references, image IDs, and SHA before switching. Roll + back with those exact digest references and `--no-build`. +- Log out in an always-running cleanup step. +- Never run migrations. + +- [ ] **Step 5: Verify production/staging compatibility** + +```bash +cd frontend && npm run test:deployment +cd .. +docker compose --env-file "$APP_ENV_FIXTURE" \ + -f deploy/docker-compose.server.yml config --quiet +DATABASE_ENV_FILE="$DATABASE_ENV_FIXTURE" \ +docker compose --env-file "$APP_ENV_FIXTURE" \ + -f deploy/docker-compose.server.yml \ + -f deploy/docker-compose.postgres.yml config --quiet +``` + +Expected: contracts PASS and both configs validate. + +- [ ] **Step 6: Commit** + +```bash +git add deploy/docker-compose.server.yml .github/workflows/deploy-staging.yml \ + frontend/tests/health-deployment.test.ts \ + frontend/tests/staging-backend-workflows.test.ts +git commit -m "ci: deploy immutable staging images" +``` + +--- + +### Task 6: Separate manual staging migration workflow + +**Files:** + +- Create: `.github/workflows/migrate-staging-database.yml` +- Modify: `frontend/tests/staging-backend-workflows.test.ts` + +- [ ] **Step 1: Add failing contracts** + +Assert manual-only dispatch, full 40-character SHA, `staging` environment, successful exact-SHA backend gate, pinned web image, both env validators, Postgres-only start, `--profile migration run --rm migrator`, filename-only ledger output, and no web/API/Caddy restart. Also assert that success dispatches `deploy-staging.yml` with the same full SHA. + +- [ ] **Step 2: Confirm red** + +```bash +cd frontend && npm run test:deployment +``` + +- [ ] **Step 3: Create workflow** + +Header: + +```yaml +name: Migrate Staging Database +on: + workflow_dispatch: + inputs: + deploy_sha: + description: Full tested commit SHA to migrate + required: true + type: string +concurrency: + group: staging-mutation + cancel-in-progress: false +permissions: + contents: read + actions: write + packages: read +jobs: + migrate: + environment: staging + runs-on: ubuntu-latest + timeout-minutes: 20 +``` + +Use the same pinned host/user/path/known-host logic as staging deploy. Reject non-`^[0-9a-f]{40}$`, require a successful backend gate for that SHA on `staging`, check it out, and rsync without `.env*`. + +Remote sequence: + +```bash +deploy/validate-staging-env.sh \ + .env.staging staging.jyotisha.chat deploy/Caddyfile.staging +deploy/validate-staging-database-env.sh .env.staging.database + +DATABASE_ENV_FILE=../.env.staging.database \ +docker compose -p jyotisha-staging \ + -f deploy/docker-compose.postgres.yml up -d --wait postgres + +DATABASE_ENV_FILE=../.env.staging.database \ +WEB_IMAGE="ghcr.io/jesse-ux/jyotisha-web:$DEPLOY_SHA" \ +docker compose -p jyotisha-staging \ + -f deploy/docker-compose.postgres.yml \ + --profile migration run --rm migrator + +docker compose -p jyotisha-staging \ + -f deploy/docker-compose.postgres.yml exec -T postgres \ + psql -U postgres -d jyotisha -Atc \ + 'select filename from migration.schema_migrations order by filename' +``` + +Authenticate GHCR through stdin using run-local Docker state and remove it in +cleanup. Do not start/restart app services. Deployment and migration share the +`staging-mutation` concurrency group and the host mutation lock. After migration +and ledger reporting succeed, recheck that `staging` still points at the validated +SHA, then call the GitHub workflow-dispatch API for `deploy-staging.yml` with the +`main` controller ref, `inputs.deploy_sha` equal to that full SHA, and +`inputs.allow_rollback` set to `false`. + +- [ ] **Step 4: Verify and commit** + +```bash +cd frontend && npm run test:deployment +cd .. +git add .github/workflows/migrate-staging-database.yml \ + frontend/tests/staging-backend-workflows.test.ts +git commit -m "ci: add manual staging database migrations" +``` + +--- + +### Task 7: Operations runbook + +**Files:** + +- Modify: `deploy/README.md` +- Modify: `frontend/tests/staging-backend-workflows.test.ts` + +- [ ] **Step 1: Add failing documentation contracts** + +Require the runbook to cover two mode-`0600` env files, exact database keys, private Postgres, manual migration order, automatic PR/`staging` gate, three encrypted local backups, no offsite staging recovery, and no production cutover authorization. + +- [ ] **Step 2: Confirm red** + +```bash +cd frontend && npm run test:deployment +``` + +- [ ] **Step 3: Document server bootstrap** + +Include: + +```bash +cd /opt/jyotisha-staging +umask 077 +touch .env.staging.database +chmod 600 .env.staging.database +``` + +Document exact keys: + +```text +POSTGRES_DB=jyotisha +POSTGRES_USER=postgres +POSTGRES_PASSWORD= +SCHEMA_OWNER_PASSWORD= +IDENTITY_RUNTIME_PASSWORD= +APP_RUNTIME_PASSWORD= +ADMIN_RUNTIME_PASSWORD= +MIGRATION_RUNNER_PASSWORD= +BACKUP_READER_PASSWORD= +STAGING_BACKUP_ENCRYPTION_KEY= +SCHEMA_DATABASE_URL=postgresql://schema_owner:@postgres:5432/jyotisha +``` + +Each secret uses independently generated 32 random bytes. URL password is percent-encoded. State explicitly: no schema URL in `.env.staging`. + +Document order: + +1. Merge the reviewed revision to `main`, then fast-forward/push that exact SHA to `staging`. +2. Wait for backend quality gate and its exact-SHA image digest manifest. +3. If automatic deploy reports pending migrations, manually run `Migrate Staging Database` with the reported full SHA. +4. The successful migration workflow re-dispatches exact-SHA staging deploy automatically. +5. Check `https://staging.jyotisha.chat/api/health`. +6. Run: + +```bash +./deploy/backup-staging-postgres.sh \ + .env.staging.database \ + /opt/jyotisha-staging/backups/staging-db +``` + +Also document a restore drill into a disposable `jyotisha_restore_check` database and deletion of only that database and temporary decrypted dump. + +- [ ] **Step 4: Verify and commit** + +```bash +cd frontend && npm run test:deployment +cd .. +git add deploy/README.md frontend/tests/staging-backend-workflows.test.ts +git commit -m "docs: add staging postgres operations runbook" +``` + +--- + +### Task 8: Milestone verification + +**Files:** Verify Tasks 1–7 only; add no feature code. + +- [ ] **Step 1: Full local gate** + +```bash +cd frontend +npm ci +npm run test:db +npm run test:deployment +npm test +npm run lint +NEXT_PUBLIC_SUPABASE_URL=https://placeholder.supabase.co \ +NEXT_PUBLIC_SUPABASE_ANON_KEY=placeholder npm run build +cd .. +python -m pip install -r requirements.txt -r requirements-dev.txt +ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py \ + tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py \ + tests/test_yoga_rules_integrity.py +python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py +python scripts/run_quality_gate.py \ + --profile quick --skip-yoga-logic --skip-frontend-runtime +python -m build --no-isolation +``` + +Expected: all PASS. + +- [ ] **Step 2: Boundary and secret scans** + +```bash +rg -n 'db:migrate([^:]|$)|migrator|profile migration' \ + .github/workflows/deploy-staging.yml +rg -n 'up -d.*--build|docker compose build' \ + .github/workflows/deploy-staging.yml +rg -n 'db:migrate|--profile migration' \ + .github/workflows/migrate-staging-database.yml +rg -n 'sb_secret_|sb_publishable_|postgresql://[^:<[:space:]]+:[^<[:space:]]+@' \ + .github deploy frontend/db frontend/scripts frontend/src/lib/db frontend/tests +``` + +Expected: first two commands have no applying-migration/build matches (the read-only `db:migrate:check` is allowed); third matches manual migration; fourth finds no real credential (inspect and allow only explicit test fixtures or documentation placeholders). + +- [ ] **Step 3: Inspect final state** + +```bash +git status --short +git diff --check +git log --oneline --decorate -8 +git diff --stat "$(git merge-base HEAD main)"..HEAD +``` + +Expected: clean worktree, no whitespace errors, seven focused commits, Milestone 1 files only. + +- [ ] **Step 4: Open implementation PR** + +Target updated `main`. Record prerequisite commit, exact test evidence, Supabase-still-source-of-truth status, normal-deploy/no-migration guarantee, server-only database env guarantee, production unchanged, and rollback rule (prior SHA image; database forward-fix unless a reviewed reverse migration exists). Do not merge until the PR’s new backend quality gate succeeds. diff --git a/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md b/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md index cd838c00..6ee361b8 100644 --- a/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md +++ b/docs/superpowers/plans/2026-07-20-staging-deployment-automation.md @@ -26,6 +26,7 @@ - Modify `deploy/docker-compose.server.yml`: environment-specific env file and Caddyfile selection while retaining production defaults. - Create `deploy/Caddyfile.staging`: staging-only public reverse proxy with no production `www` redirect. +- Create `deploy/validate-staging-env.sh`: fail closed unless the staging env is mode `0600` and contains exactly the three fixed staging selectors. - Modify `frontend/tests/health-deployment.test.ts`: Compose, Caddy, CI-trigger, and staging-workflow contracts. - Modify `.github/workflows/ci.yml`: run the existing CI on pushes to `staging`; do not add a `main` push trigger in this task. - Create `.github/workflows/deploy-staging.yml`: tested-revision staging deployment and smoke checks. @@ -183,12 +184,19 @@ test("staging deploy consumes only the isolated staging environment and tested r assert.match(ci, /push:\s*\n\s*branches: \[staging\]/); assert.match(workflow, /workflows: \["Jyotish Skill CI"\]/); assert.match(workflow, /github\.event\.workflow_run\.head_branch == 'staging'/); + assert.match(workflow, /actions: read/); assert.match(workflow, /environment:\s*\n\s*name: staging/); + assert.match(workflow, /git_sha:/); + assert.doesNotMatch(workflow, /default: staging/); + assert.match(workflow, /test "\$\{#REQUESTED_SHA\}" -eq 40/); + assert.match(workflow, /actions\/workflows\/ci\.yml\/runs\?head_sha=/); assert.match(workflow, /STAGING_SSH_PRIVATE_KEY/); assert.match(workflow, /vars\.STAGING_HOST/); assert.match(workflow, /vars\.STAGING_KNOWN_HOSTS/); - assert.match(workflow, /--exclude='\.env\.staging'/); + assert.match(workflow, /--exclude='\.env\*'/); assert.match(workflow, /docker compose --env-file \.env\.staging/); + assert.match(workflow, /bash deploy\/validate-staging-env\.sh \.env\.staging/); + assert.match(workflow, /docker compose --env-file \.env\.staging -f deploy\/docker-compose\.server\.yml config --quiet/); assert.match(workflow, /deployment\.gitCommit/); assert.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/); assert.doesNotMatch(workflow, /103\.117\.123\.53/); @@ -230,13 +238,13 @@ on: types: [completed] workflow_dispatch: inputs: - git_ref: - description: Tested branch, tag, or commit SHA to deploy + git_sha: + description: Exact 40-character commit SHA from a successful CI run required: true - default: staging permissions: contents: read + actions: read concurrency: group: staging @@ -263,25 +271,48 @@ jobs: STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} steps: + - name: Validate tested revision + id: revision + env: + REQUESTED_SHA: ${{ github.event.workflow_run.head_sha || inputs.git_sha }} + GH_TOKEN: ${{ github.token }} + run: | + test "${#REQUESTED_SHA}" -eq 40 + case "$REQUESTED_SHA" in + *[!0-9a-fA-F]*) echo "git_sha must be a full hexadecimal commit SHA" >&2; exit 1 ;; + esac + DEPLOY_GIT_SHA="$(printf '%s' "$REQUESTED_SHA" | tr '[:upper:]' '[:lower:]')" + if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; then + TESTED_RUNS="$(curl --fail --silent --show-error \ + --header "Authorization: Bearer $GH_TOKEN" \ + --header "Accept: application/vnd.github+json" \ + --header "X-GitHub-Api-Version: 2022-11-28" \ + "$GITHUB_API_URL/repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs?head_sha=$DEPLOY_GIT_SHA&status=success&per_page=1")" + test "$(printf '%s' "$TESTED_RUNS" | jq -r '.total_count')" -ge 1 || { + echo "No successful Jyotish Skill CI run found for $DEPLOY_GIT_SHA" >&2 + exit 1 + } + fi + echo "sha=$DEPLOY_GIT_SHA" >> "$GITHUB_OUTPUT" + - name: Checkout tested revision uses: actions/checkout@v4 with: - ref: ${{ github.event.workflow_run.head_sha || inputs.git_ref }} + ref: ${{ steps.revision.outputs.sha }} - - name: Resolve deployment SHA - id: revision - run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Verify checked-out revision + env: + DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + run: test "$(git rev-parse HEAD)" = "$DEPLOY_GIT_SHA" - name: Validate staging target configuration run: | - test -n "$DEPLOY_HOST" - test -n "$DEPLOY_PORT" - test -n "$DEPLOY_USER" - test -n "$DEPLOY_PATH" - test -n "$STAGING_URL" - test -n "$STAGING_KNOWN_HOSTS" - test "$DEPLOY_HOST" != "103.117.123.53" + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" test "$STAGING_URL" = "https://staging.jyotisha.chat" + test -n "$STAGING_KNOWN_HOSTS" - name: Configure pinned staging SSH env: @@ -294,6 +325,13 @@ jobs: printf '%s\n' "$STAGING_KNOWN_HOSTS" > ~/.ssh/known_hosts chmod 600 ~/.ssh/known_hosts + - name: Record previous staging state + env: + DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} + run: | + # Query the current public deployment SHA and current Compose image IDs. + # Append both, plus DEPLOY_GIT_SHA, to GITHUB_STEP_SUMMARY before rebuilding. + - name: Sync and rebuild staging env: DEPLOY_GIT_SHA: ${{ steps.revision.outputs.sha }} @@ -303,14 +341,13 @@ jobs: ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "install -d -m 755 '$DEPLOY_PATH'" rsync -az --delete \ --exclude='.git/' \ - --exclude='.env.production' \ - --exclude='.env.staging' \ + --exclude='.env*' \ --exclude='frontend/node_modules/' \ --exclude='frontend/.next/' \ -e "$RSYNC_SSH" \ ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "cd '$DEPLOY_PATH' && test -f .env.staging && GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml up -d --build --remove-orphans" + "cd '$DEPLOY_PATH' && bash deploy/validate-staging-env.sh .env.staging && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet && APP_ENV_FILE='../.env.staging' CADDYFILE_PATH='./Caddyfile.staging' SITE_ADDRESS='https://staging.jyotisha.chat' GITHUB_SHA='$DEPLOY_GIT_SHA' docker compose --env-file .env.staging -f deploy/docker-compose.server.yml up -d --build --remove-orphans" - name: Verify staging env: @@ -379,17 +416,18 @@ Staging is isolated from production: | Supabase | separate `jyotisha-staging` project | | GitHub Environment | `staging` | -The GitHub Environment contains `STAGING_SSH_PRIVATE_KEY` and the variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. The staging key, database, Supabase keys, and model-provider keys must not be shared with production. +The GitHub Environment contains `STAGING_SSH_PRIVATE_KEY` and the variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. Its deployment policy allows the `main` controller branch; the workflow separately requires an upstream successful CI push from branch `staging`. The staging key, database, Supabase keys, and model-provider keys must not be shared with production. -A push to branch `staging` runs `Jyotish Skill CI`. A successful push run triggers `.github/workflows/deploy-staging.yml`, which deploys the tested SHA and verifies the login route, logged-out account response, deployment SHA, and private Python health endpoint. +A push to branch `staging` runs `Jyotish Skill CI`. A successful push run triggers `.github/workflows/deploy-staging.yml`, which records the previous SHA/images, validates the env selectors and Compose configuration, deploys the tested SHA, and verifies the login route, logged-out account response, deployment SHA, and private Python health endpoint. -The first deployment should be manual: +The first deployment should be manual, after `.env.staging` is verified to contain `APP_ENV_FILE=../.env.staging`, `CADDYFILE_PATH=./Caddyfile.staging`, and `SITE_ADDRESS=https://staging.jyotisha.chat`: 1. Confirm `/opt/jyotisha-staging/.env.staging` exists and has mode `0600`. -2. Open GitHub Actions -> Deploy staging -> Run workflow. -3. Enter the tested commit SHA in `git_ref`. -4. Confirm `https://staging.jyotisha.chat/api/health` reports that SHA. -5. Only after the manual deployment passes, push the same revision to branch `staging` to validate automatic deployment. +2. Run `Jyotish Skill CI` manually using workflow from `main` and wait for success. +3. Open GitHub Actions -> Deploy staging -> Run workflow, using workflow from `main`. +4. Enter that successful CI run's exact 40-character commit SHA in `git_sha`. +5. Confirm `https://staging.jyotisha.chat/api/health` reports that SHA. +6. Only after the manual deployment passes, push a reviewed revision to branch `staging` to validate automatic deployment. Application rollback uses the same workflow: manually dispatch `Deploy staging` with the previous known-good commit SHA. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal. @@ -482,8 +520,8 @@ In GitHub: ```text Actions -> Deploy staging -> Run workflow -Use workflow from -> staging -git_ref -> exact reviewed commit SHA +Use workflow from -> main +git_sha -> exact 40-character SHA from a successful Jyotish Skill CI run ``` Expected: `Configure pinned staging SSH`, `Sync and rebuild staging`, and `Verify staging` all pass. GitHub Environment shows the deployment URL. @@ -531,7 +569,7 @@ cp -p .env.staging /home/deploy/.env.staging.health-rehearsal sed -i 's/^SUPABASE_SERVICE_ROLE_KEY=.*/SUPABASE_SERVICE_ROLE_KEY=/' .env.staging ``` -Manually dispatch `Deploy staging` using the current tested SHA and `Use workflow from -> staging`. +Manually dispatch `Deploy staging` from `main` using the current full SHA that already has a successful `Jyotish Skill CI` run. Expected: deployment reaches `Verify staging`, `/api/health` is not `ok`, and GitHub marks the workflow failed rather than successful. diff --git a/docs/superpowers/plans/2026-07-20-staging-infrastructure-bootstrap.md b/docs/superpowers/plans/2026-07-20-staging-infrastructure-bootstrap.md index 43252e3f..6739218c 100644 --- a/docs/superpowers/plans/2026-07-20-staging-infrastructure-bootstrap.md +++ b/docs/superpowers/plans/2026-07-20-staging-infrastructure-bootstrap.md @@ -536,9 +536,9 @@ Repository -> Settings -> Environments -> New environment Name: staging ``` -Set deployment branches to `Selected branches and tags`, then allow only branch pattern `staging`. +Set deployment branches to `Selected branches and tags`, then allow only branch pattern `main`. -Expected: the Environment page displays `staging` and its branch policy. +Expected: the Environment page displays `staging` and allows the `main` controller branch. GitHub evaluates Environment branch rules against the deployment workflow's own `GITHUB_REF`; a `workflow_run` controller executes from the default branch even when the tested upstream revision came from branch `staging`. The workflow separately enforces `head_branch == 'staging'` and deploys the upstream `head_sha`. - [ ] **Step 2: Add the staging SSH private key as an Environment secret** @@ -578,7 +578,7 @@ Expected Environment inventory: ```text Secrets (1): STAGING_SSH_PRIVATE_KEY Variables (6): STAGING_HOST, STAGING_PORT, STAGING_USER, STAGING_PATH, STAGING_URL, STAGING_KNOWN_HOSTS -Allowed branch: staging +Allowed controller branch: main ``` GitHub Environment secrets become available only to jobs that explicitly reference that Environment: . diff --git a/docs/superpowers/specs/2026-07-20-staging-server-design.md b/docs/superpowers/specs/2026-07-20-staging-server-design.md index bfb65ae1..31da798d 100644 --- a/docs/superpowers/specs/2026-07-20-staging-server-design.md +++ b/docs/superpowers/specs/2026-07-20-staging-server-design.md @@ -18,7 +18,7 @@ | 部署密钥 | production 专用 | staging 专用 | | 应用配置 | `.env.production` | `.env.staging` | | Supabase | 生产项目 | 独立 staging 项目 | -| 部署触发 | `main` CI 成功 | `staging` CI 成功或手动触发 | +| 部署触发 | 手动 production workflow | `staging` CI 成功或手动触发 | staging 不得写入生产数据库,不得复用 service-role key、数据库密码、SSH 私钥或模型计费密钥。模型接口优先使用独立测试 key、低额度或 provider sandbox。 @@ -60,7 +60,7 @@ staging 配置包含: - Secret:`STAGING_SSH_PRIVATE_KEY`; - Variable:`STAGING_HOST=118.26.111.127`、SSH port/user/path、staging URL; -- 只允许 `staging` 分支使用; +- GitHub Environment 只允许控制器分支 `main` 使用;`workflow_run` 另外强制上游成功运行来自 `staging`,并部署其 `head_sha`; - staging 部署使用独立 concurrency group,不能阻塞或取消 production。 部署流: @@ -69,7 +69,9 @@ staging 配置包含: push staging -> Jyotish Skill CI -> checkout 已测试 SHA - -> rsync 到 /opt/jyotisha-staging(排除 .env.staging) + -> 记录旧 SHA 和镜像 ID + -> rsync 到 /opt/jyotisha-staging(排除所有 .env*) + -> 校验 .env.staging 权限、固定选择器和 Compose 配置,并在 Compose 进程上显式钉死 staging 选择器 -> docker compose build/up -> login、401 account、Python health smoke tests -> 记录部署 SHA diff --git a/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md b/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md new file mode 100644 index 00000000..1484b232 --- /dev/null +++ b/docs/superpowers/specs/2026-07-20-supabase-exit-backend-design.md @@ -0,0 +1,317 @@ +# Jyotisha Supabase Exit and Self-Hosted Backend Design + +Date: 2026-07-20 +Status: approved in conversation; awaiting written-spec review + +## Goal + +Replace Supabase completely with a self-hosted PostgreSQL backend while preserving every existing user and business record. Keep the current email-code login experience through Resend, add a business administration UI on a separate hostname, and prove the migration on the staging VPS before provisioning a future production server. + +The current production application and Supabase project remain authoritative until a separately approved production cutover. This design does not authorize deleting, mutating, or disabling the production Supabase project. + +## Confirmed decisions + +- Preserve all user UUIDs, email addresses, credits, transactions, chats, profiles, chart profiles, synastry reports, rectification cases, scoring jobs, and action receipts. +- Keep passwordless email OTP login and use Resend for delivery. +- Use a Next.js modular monolith for the application backend; keep the Python API focused on astrology calculation. +- Run PostgreSQL 17 on the staging VPS without exposing a host database port. +- Use `staging.jyotisha.chat` for the staging user application and `admin.staging.jyotisha.chat` for the staging administration UI. +- Reserve `admin.jyotisha.chat` for a future production administration UI. +- Keep user and admin host-only session cookies separate. +- Build a business administration UI, not a browser-based SQL editor. +- Permit a future 10–20 minute production maintenance window. +- Do not turn `118.26.111.127` into the final production database server. A new production server will be provisioned after staging proves the design. +- Defer off-site backup setup on disposable staging. Off-site encrypted backup and a restore drill are hard gates for production. +- Add a new automatic GitHub Actions backend quality gate for pull requests and pushes to `staging`, with manual dispatch retained. + +## Current coupling and migration boundary + +The application is not coupled only to a PostgreSQL connection string. It currently depends on: + +- Supabase Auth and `auth.users`; +- browser and server Supabase clients; +- PostgREST/Data API table access; +- RLS policies based on `auth.uid()` and `auth.jwt()`; +- `anon`, `authenticated`, and `service_role` database roles; +- security-definer RPCs for credits and rectification state transitions; +- Supabase session cookies and JWT validation. + +The repository currently contains 29 Supabase migrations and many direct table/RPC calls. The exit therefore requires replacement interfaces for identity, authorization, data access, atomic business transitions, operations, and migration—not a connection-string edit. + +## Chosen architecture + +```text +Caddy +├── staging.jyotisha.chat +│ └── user-facing Next.js routes +└── admin.staging.jyotisha.chat + └── isolated administration routes and login + +Next.js modular monolith +├── Identity module ── Better Auth ── Resend adapter +├── Account and Credit module +├── Chat module +├── Chart Profile module +├── Synastry module +├── Rectification module +├── Admin and Audit module +├── PostgreSQL adapters ── private PostgreSQL 17 +└── Astrology adapter ── private Python API +``` + +The user and admin surfaces share one Next.js image and container initially, but their host routing, pages, cookies, authorization checks, and navigation remain separate. This keeps the initial 2-core/4-GB server viable without coupling the module interfaces to a single deployment topology. A future deployment may split the admin surface into its own process without changing the domain modules. + +## Deep module seams + +Only adapters at these seams may know database tables, Better Auth internals, Resend payloads, or Python wire formats. + +| Module | Interface responsibilities | Hidden implementation | +| --- | --- | --- | +| Identity | request/verify OTP, load/revoke session, require user/admin | Better Auth, Resend, identity tables, cookie configuration | +| Account and Credit | load account, reserve/complete/refund/adjust credit, redeem code | transactions, ledgers, idempotency, database functions | +| Chat | list/create/update/delete owned sessions | PostgreSQL queries and ownership policies | +| Chart Profile | CRUD for owned chart profiles | serialization, owner scoping, persistence | +| Synastry | list/create/delete owned reports | report persistence and owner scoping | +| Rectification | load and advance guarded state machines | atomic functions, jobs, receipts, concurrency guards | +| Admin and Audit | user status, session revocation, credit adjustment, codes, audit search | admin authorization, immutable audit records | +| Migration | export, transform, import, reconcile | Supabase extraction and PostgreSQL bulk loading | + +Callers and tests cross the same interfaces. Raw Supabase clients, raw database clients, and table names must not escape these modules. + +## PostgreSQL schemas and roles + +### Schemas + +- `identity`: Better Auth users, sessions, accounts, verifications, roles, and ban state. +- `public`: existing business tables, retained initially to minimize rename risk. +- `audit`: immutable administration and security events. +- `migration`: temporary import manifests and reconciliation results; production runtime roles receive no access. + +Better Auth uses UUID identifiers. Existing `auth.users.id` values are inserted unchanged into `identity.users`; business foreign keys are repointed to that table. New users and sessions also use UUIDs. Supabase sessions and JWTs are not migrated, so every user re-authenticates once after cutover. + +### Database roles + +- `schema_owner`: owns schemas and applies reviewed migrations; never used by the running app. +- `identity_runtime`: limited to Better Auth identity tables. +- `app_runtime`: limited to required business tables/functions and subject to user-scoped policies. +- `admin_runtime`: may execute audited admin functions but cannot issue arbitrary SQL through the UI. +- `migration_runner`: temporary bulk import and reconciliation access. +- `backup_reader`: production-only read access required by backup tooling. + +The application uses small independent pools for identity and business operations. Staging starts with at most five application connections per pool and no PgBouncer. + +Drizzle supplies runtime query typing and the Better Auth database adapter. Reviewed plain SQL remains the migration source of truth because the application depends on PostgreSQL functions, triggers, grants, and RLS policies that must not be flattened or regenerated by an ORM migration diff. + +## Authorization and RLS replacement + +Removing Supabase does not mean silently deleting its authorization model. + +1. The server validates a Better Auth session before entering a domain module. +2. User-scoped operations receive the authenticated UUID from trusted server context, never from a browser-owned `user_id`. +3. Each user transaction sets a transaction-local `app.user_id` PostgreSQL setting. +4. User-owned table policies read that setting and keep row isolation as defense in depth. +5. Repository queries still include explicit owner predicates; RLS is not a substitute for correct queries. +6. Admin changes execute through narrow audited functions. The admin UI does not receive a `BYPASSRLS` connection. +7. System jobs use explicit, narrowly granted functions rather than pretending to be an end user. + +Every existing Supabase policy and grant must appear in a migration authorization matrix with one of three dispositions: preserved as PostgreSQL policy, replaced by server authorization plus a constrained function, or removed with a documented reason and regression test. + +## Authentication and Resend + +Better Auth provides UUID-backed PostgreSQL persistence and its Email OTP and Admin plugins. The initial configuration is: + +- six-digit codes; +- five-minute expiry; +- three verification attempts; +- rotate and invalidate an older code on resend; +- store only a cryptographic hash of the OTP; +- rate-limit by normalized email and source IP; +- return enumeration-safe responses; +- use host-only, secure, HTTP-only, same-site cookies; +- use distinct cookie prefixes and secrets for staging and production; +- keep Resend credentials server-only. + +The staging user and staging admin hosts require separate logins. Staging admin access requires a persisted admin role. `ADMIN_EMAILS` may bootstrap the first role but is not the long-term authority. + +Production administration requires email OTP followed by TOTP. Staging may initially use email OTP alone while TOTP is implemented and tested before production readiness is declared. + +## Business administration UI + +The administration UI supports: + +- user search and account inspection; +- ban/unban and session revocation; +- credit balance and ledger inspection; +- credit adjustment by appending an idempotent ledger entry, never overwriting a balance; +- redemption code generation, lookup, and deactivation; +- metadata-only inspection of chats, charts, synastry, and rectification records by default; +- database connectivity, disk utilization, and last-backup status; +- immutable audit history. + +Every mutation records actor UUID, target, operation, reason, idempotency/request ID, timestamp, and before/after facts. Every `/api/admin/**` route revalidates the admin session and role on the server. Hiding a control in the browser is never treated as authorization. + +## Runtime topology on staging + +The staging stack contains Caddy, Next.js, Python API, and `postgres:17-alpine`. PostgreSQL uses a named volume, a health check, conservative memory/connection settings, and no published host port. SSH and Caddy remain the only public ingress paths. + +The staging VPS remains disposable: + +- Supabase production is the data authority. +- Local compressed, encrypted dumps are limited to the newest two or three copies. +- Local dumps are not disaster-recovery backups. +- Imports stop if disk utilization reaches 70%. +- Production data copied into staging receives the same access restrictions as production data and is deleted when the rehearsal ends. + +Sustained swap use, database saturation, disk above 70%, or unacceptable request latency triggers a capacity review. The user has chosen not to upgrade the current staging VPS before those signals appear. + +## Build and deployment + +The VPS must not compile large application images while PostgreSQL is serving tests. GitHub Actions builds web/API images, publishes SHA tags only for discovery, and records the build outputs' immutable manifest digests in an artifact bound to the successful quality-gate run. The VPS validates that artifact and pulls digest references only. + +Application deployment and database migration remain different operations: + +- application deployment may pull and restart images; +- schema migration is a visible, manually approved job against the intended environment; +- data import/reconciliation is a separate migration-runner operation; +- normal deploys never run database migration implicitly. + +Before switching application containers, the staging deployment workflow runs the +exact SHA web image in read-only migration-check mode. With no pending migrations +it continues automatically. With pending or drifted migrations it stops before +touching the running application. After the operator runs the manual migration +workflow successfully, that workflow dispatches staging deployment again for the +same full SHA. The check may read the migration ledger but may never apply SQL. + +Deployment and migration share one Actions concurrency group and one host-side lock covering live-tree synchronization through their final database/application verification. The `main` controller owns manifest validation and remote orchestration: it requires a target SHA already present in reviewed `main` history, uploads only allowlisted controller files, and never executes deployment scripts from the target or rollback revision. Deployment records the previous application SHA, image digests, and image IDs. It rejects stale or backward automatic revisions, verifies running container image IDs/RepoDigests plus the application-reported SHA, and requires public and private health checks before updating deployed-revision state. An older application revision requires an explicit manual rollback authorization; application rollback does not claim to roll back database state. + +## Automatic backend quality gate + +Create `.github/workflows/backend-quality-gate.yml` with these triggers: + +- `pull_request` for relevant application, migration, deployment, and workflow paths; +- `push` to `staging`; +- `workflow_dispatch`. + +The user explicitly approved automatic execution for this non-production quality gate and for successful `staging` deployments. Production deployment and production migration remain manual-only. + +The workflow uses a temporary PostgreSQL 17 service and generated non-production credentials. It uses a fake Resend adapter and sends no real email. It must run: + +1. clean-database migration from zero; +2. Better Auth UUID and email-OTP integration tests; +3. repository ownership and RLS integration tests; +4. credit, refund, redemption, idempotency, and concurrency tests; +5. chat, chart, synastry, rectification, admin, and audit integration tests; +6. representative Supabase-export transformation and reconciliation tests; +7. frontend unit tests, lint, and production build; +8. Docker Compose rendering and health-contract checks. + +Pull requests test only and cannot publish or deploy. A successful push run on `staging` is the only automatic deployment prerequisite. The deploy workflow consumes that run's immutable `head_sha`; it must not deploy a moving branch ref. New commits cancel older in-progress quality-gate runs for the same ref. Reconciliation reports and useful failure logs are uploaded as artifacts without credentials or personal data. + +## Data migration strategy + +### Staging rehearsal + +1. Build an empty target database entirely from reviewed migrations. +2. Export selected Supabase identity columns and all required public business data without copying Supabase platform internals into runtime schemas. +3. Import users first, preserving UUID and normalized email. +4. Import dependent business tables in foreign-key order. +5. Transform Supabase grants, `auth.uid()`/`auth.jwt()` policies, and `service_role` functions into the new roles and authorization context. +6. Record source and target counts, key-set hashes, foreign-key failures, and credit-ledger reconciliation. +7. Run authenticated user and admin journeys against the imported copy. +8. On any mismatch, discard the target database and rerun from source. Do not repair an unexplained mismatch by hand. + +The reconciliation gate includes, at minimum: + +- exact user UUID and email sets; +- row counts for every migrated table; +- zero orphaned foreign keys; +- per-user profile, chat, chart, synastry, and rectification ownership; +- per-user credit balance equal to the accepted ledger result; +- unique request/action/idempotency identities; +- sampled semantic equality for JSON payloads and timestamps. + +### Future production cutover + +The future production server is provisioned separately. Before cutover it must have encrypted off-site backups, retention policy, monitoring, and a successful restore drill. + +The approved cutover sequence is: + +1. complete a fresh rehearsal using the production migration artifact; +2. enable maintenance mode and stop production writes; +3. take a final Supabase export and immutable backup; +4. import and reconcile the new production PostgreSQL database; +5. run OTP, account, credit, chat, chart, synastry, rectification, admin, and health smoke tests; +6. switch application configuration and domains only after reconciliation succeeds; +7. keep the former Supabase project intact and read-only during the observation window; +8. retire Supabase only under a separate reviewed decision. + +If import or verification fails inside the maintenance window, the new database is abandoned and the existing application/Supabase write path is restored. Once writes begin on the new database, rollback requires a written data-reconciliation procedure; DNS rollback alone is not a database rollback. + +## Failure handling + +- Resend failure creates no authenticated session and returns a generic retryable error. +- OTP exhaustion invalidates the verification record and requires a new code. +- Database mutations run transactionally and expose typed domain errors, not driver messages. +- Credit and admin mutations use unique idempotency keys; retries replay the committed result. +- Migration count, UUID, foreign-key, ledger, or checksum mismatch fails closed. +- Failed staging health checks retain the prior application revision and database volume for inspection. +- Destructive automatic database rollback, volume deletion, and automatic production migration are forbidden. + +## Verification strategy + +- Unit tests exercise domain rules through module interfaces. +- PostgreSQL integration tests exercise the same adapters used at runtime. +- Authorization tests prove cross-user reads/writes fail at both repository and policy layers. +- Concurrency tests cover credits, refunds, codes, scoring jobs, and exact action receipts. +- Browser tests cover OTP login, logout, session expiry, user journeys, admin isolation, and TOTP before production. +- Migration tests start from representative Supabase exports and produce machine-readable reconciliation reports. +- Restore drills prove production backups can create a working database on a clean host. + +## Implementation decomposition + +This program is intentionally larger than one implementation plan. It is delivered through independently reviewed milestones, each ending in a runnable system and evidence-backed gate: + +1. **Database and quality-gate foundation:** PostgreSQL staging topology, roles, migration runner, test fixtures, automatic backend quality workflow, and GHCR image path. +2. **Identity replacement:** Better Auth UUID schema, Resend/fake adapters, user/admin host isolation, session APIs, and Supabase-user import fixture. +3. **Business module migration:** move browser and server Supabase access behind domain interfaces. Account/credit is migrated first, then chat, charts/synastry, and rectification in separate task groups. +4. **Administration:** independent admin host, role/TOTP policy, user/session operations, credit/code tools, and immutable audit trail. +5. **Migration and reconciliation:** full export-transform-import tooling, authorization matrix, deterministic reports, and repeatable staging rehearsals. +6. **Future production readiness:** new server, off-site backups, restore drill, capacity gate, maintenance procedure, and separately approved cutover. + +No milestone may remove the preceding working path until its replacement passes its own integration, migration, and rollback gates. The first implementation plan covers milestone 1 only. + +## Acceptance criteria + +Staging is complete only when: + +- no runtime source imports `@supabase/*` or reads Supabase environment variables; +- no browser performs direct database or PostgREST access; +- all 29 legacy migration outcomes have an explicit preserved/replaced/retired disposition; +- all existing user UUIDs and business records reconcile on a staging import; +- email OTP through the Resend adapter creates a valid host-only session; +- user and admin hosts do not share cookies or authorization state; +- cross-user and non-admin access tests fail closed; +- critical atomic business flows pass concurrency tests; +- the automatic backend quality gate succeeds for the exact deployed SHA; +- the staging VPS can be rebuilt from source, migrations, and an import artifact without Supabase runtime services. + +Production readiness additionally requires a new production server, encrypted off-site backups, a successful restore drill, admin TOTP, capacity validation, and a separately approved cutover plan. + +## Non-goals + +- Self-hosting the Supabase Docker stack. +- Turning the current staging VPS into the final production database server. +- Exposing PostgreSQL, a SQL editor, or a database dashboard publicly. +- Migrating active Supabase JWT sessions. +- Deleting the production Supabase project during staging development. +- Adding zero-downtime dual writes; the approved production path uses a short maintenance window. + +## Primary references + +- Better Auth database and UUID configuration: +- Better Auth Email OTP: +- Better Auth Admin plugin: +- Better Auth Supabase migration guide: +- Drizzle transactions: +- Drizzle PostgreSQL RLS: +- Supabase platform-to-self-hosted export concepts: diff --git a/frontend/db/migrations/20260720000100_backend_foundation.sql b/frontend/db/migrations/20260720000100_backend_foundation.sql new file mode 100644 index 00000000..b20e845e --- /dev/null +++ b/frontend/db/migrations/20260720000100_backend_foundation.sql @@ -0,0 +1,17 @@ +create schema if not exists identity authorization schema_owner; +create schema if not exists audit authorization schema_owner; +revoke all on schema public from public; +revoke all on schema identity from public; +revoke all on schema audit from public; +grant usage on schema identity to identity_runtime, admin_runtime; +grant usage on schema public to app_runtime, admin_runtime; +grant usage on schema audit to admin_runtime; + +alter default privileges for role schema_owner + revoke execute on functions from public; +alter default privileges for role schema_owner in schema identity + revoke all on tables from public; +alter default privileges for role schema_owner in schema public + revoke all on tables from public; +alter default privileges for role schema_owner in schema audit + revoke all on tables from public; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 519aab35..27b51bf3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,8 +16,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", "lucide-react": "^1.24.0", "next": "16.2.10", + "pg": "^8.22.0", "react": "19.2.4", "react-day-picker": "^10.0.1", "react-dom": "19.2.4", @@ -30,6 +32,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", @@ -3017,12 +3020,24 @@ "version": "20.19.43", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -4730,6 +4745,131 @@ "url": "https://dotenvx.com" } }, + "node_modules/drizzle-orm": { + "version": "0.45.2", + "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@aws-sdk/client-rds-data": ">=3", + "@cloudflare/workers-types": ">=4", + "@electric-sql/pglite": ">=0.2.0", + "@libsql/client": ">=0.10.0", + "@libsql/client-wasm": ">=0.10.0", + "@neondatabase/serverless": ">=0.10.0", + "@op-engineering/op-sqlite": ">=2", + "@opentelemetry/api": "^1.4.1", + "@planetscale/database": ">=1.13", + "@prisma/client": "*", + "@tidbcloud/serverless": "*", + "@types/better-sqlite3": "*", + "@types/pg": "*", + "@types/sql.js": "*", + "@upstash/redis": ">=1.34.7", + "@vercel/postgres": ">=0.8.0", + "@xata.io/client": "*", + "better-sqlite3": ">=7", + "bun-types": "*", + "expo-sqlite": ">=14.0.0", + "gel": ">=2", + "knex": "*", + "kysely": "*", + "mysql2": ">=2", + "pg": ">=8", + "postgres": ">=3", + "sql.js": ">=1", + "sqlite3": ">=5" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-rds-data": { + "optional": true + }, + "@cloudflare/workers-types": { + "optional": true + }, + "@electric-sql/pglite": { + "optional": true + }, + "@libsql/client": { + "optional": true + }, + "@libsql/client-wasm": { + "optional": true + }, + "@neondatabase/serverless": { + "optional": true + }, + "@op-engineering/op-sqlite": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@planetscale/database": { + "optional": true + }, + "@prisma/client": { + "optional": true + }, + "@tidbcloud/serverless": { + "optional": true + }, + "@types/better-sqlite3": { + "optional": true + }, + "@types/pg": { + "optional": true + }, + "@types/sql.js": { + "optional": true + }, + "@upstash/redis": { + "optional": true + }, + "@vercel/postgres": { + "optional": true + }, + "@xata.io/client": { + "optional": true + }, + "better-sqlite3": { + "optional": true + }, + "bun-types": { + "optional": true + }, + "expo-sqlite": { + "optional": true + }, + "gel": { + "optional": true + }, + "knex": { + "optional": true + }, + "kysely": { + "optional": true + }, + "mysql2": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "prisma": { + "optional": true + }, + "sql.js": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -8958,6 +9098,95 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -9024,6 +9253,45 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/posthog-node": { "version": "5.41.0", "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.41.0.tgz", @@ -9863,6 +10131,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -10543,7 +10820,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -10933,6 +11210,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/xxhash-wasm": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 10d41ed1..fd4f53ed 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,10 @@ "build": "next build", "start": "next start", "test": "tsx --test tests/*.test.ts", + "test:db": "tsx --test --test-concurrency=1 tests/database-*.test.ts", + "test:deployment": "tsx --test tests/health-deployment.test.ts tests/staging-backend-workflows.test.ts tests/staging-image-manifest.test.ts", + "db:migrate": "node scripts/db-migrate.mjs", + "db:migrate:check": "node scripts/db-migrate.mjs --check", "lint": "eslint", "data:china": "node scripts/pull-china-locations.mjs" }, @@ -20,8 +24,10 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", + "drizzle-orm": "^0.45.2", "lucide-react": "^1.24.0", "next": "16.2.10", + "pg": "^8.22.0", "react": "19.2.4", "react-day-picker": "^10.0.1", "react-dom": "19.2.4", @@ -34,6 +40,7 @@ }, "devDependencies": { "@types/node": "^20", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", diff --git a/frontend/scripts/db-migrate.mjs b/frontend/scripts/db-migrate.mjs new file mode 100644 index 00000000..8433dbc9 --- /dev/null +++ b/frontend/scripts/db-migrate.mjs @@ -0,0 +1,208 @@ +import { createHash } from "node:crypto"; +import { readFile, readdir } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import pg from "pg"; + +const { Client } = pg; +const migrationFilenamePattern = /^\d{14}_[a-z0-9_]+\.sql$/; + +class SafeMigrationError extends Error {} + +async function loadMigrationFiles(migrationsDirectory) { + let entries; + try { + entries = await readdir(migrationsDirectory, { withFileTypes: true }); + } catch { + throw new SafeMigrationError("unable to read migrations directory"); + } + + const malformedSqlEntry = entries.find( + (entry) => + entry.isFile() && + entry.name.endsWith(".sql") && + !migrationFilenamePattern.test(entry.name), + ); + if (malformedSqlEntry) { + throw new SafeMigrationError( + `invalid migration filename: ${malformedSqlEntry.name}`, + ); + } + + return Promise.all( + entries + .filter( + (entry) => entry.isFile() && migrationFilenamePattern.test(entry.name), + ) + .map((entry) => entry.name) + .sort() + .map(async (filename) => { + const bytes = await readFile(resolve(migrationsDirectory, filename)); + return { + filename, + bytes, + checksum: createHash("sha256").update(bytes).digest("hex"), + }; + }), + ); +} + +async function readLedger(client) { + const ledgerResult = await client.query( + "select to_regclass('migration.schema_migrations') as ledger", + ); + if (ledgerResult.rows[0]?.ledger === null) return new Map(); + + const result = await client.query( + "select filename, checksum from migration.schema_migrations", + ); + return new Map(result.rows.map((row) => [row.filename, row.checksum])); +} + +function assertLedgerFilesPresent(ledger, files) { + const reviewedFilenames = new Set(files.map((file) => file.filename)); + for (const filename of ledger.keys()) { + if (!reviewedFilenames.has(filename)) { + if (!migrationFilenamePattern.test(filename)) { + throw new SafeMigrationError( + "migration ledger contains an invalid filename", + ); + } + throw new SafeMigrationError(`migration file missing: ${filename}`); + } + } +} + +export async function runMigrations({ + connectionString, + migrationsDirectory, + logger = console, + check = false, +}) { + const files = await loadMigrationFiles(migrationsDirectory); + const client = new Client({ connectionString }); + let locked = false; + + try { + await client.connect(); + await client.query( + "select pg_advisory_lock(hashtext('jyotisha_schema_migrations'))", + ); + locked = true; + + if (check) { + const ledger = await readLedger(client); + const pending = []; + assertLedgerFilesPresent(ledger, files); + + for (const file of files) { + const recordedChecksum = ledger.get(file.filename); + if (recordedChecksum === undefined) { + pending.push(file.filename); + } else if (recordedChecksum !== file.checksum) { + throw new SafeMigrationError( + `migration checksum mismatch: ${file.filename}`, + ); + } + } + + for (const filename of pending) logger.log(filename); + return pending.length === 0 ? 0 : 3; + } + + await client.query( + "create schema if not exists migration authorization schema_owner", + ); + await client.query("revoke all on schema migration from public"); + await client.query(` + create table if not exists migration.schema_migrations ( + filename text primary key, + checksum text not null check (length(checksum) = 64), + applied_at timestamptz not null default now() + ) + `); + await client.query( + "revoke all on table migration.schema_migrations from public", + ); + + const ledger = await readLedger(client); + assertLedgerFilesPresent(ledger, files); + for (const file of files) { + const recordedChecksum = ledger.get(file.filename); + if (recordedChecksum !== undefined) { + if (recordedChecksum !== file.checksum) { + throw new SafeMigrationError( + `migration checksum mismatch: ${file.filename}`, + ); + } + logger.log(`already applied ${file.filename}`); + continue; + } + + await client.query("begin"); + try { + await client.query(file.bytes.toString("utf8")); + await client.query( + "insert into migration.schema_migrations (filename, checksum) values ($1, $2)", + [file.filename, file.checksum], + ); + await client.query("commit"); + } catch { + await client.query("rollback"); + throw new SafeMigrationError(`migration failed: ${file.filename}`); + } + logger.log(`applied ${file.filename}`); + } + + return 0; + } finally { + if (locked) { + try { + await client.query( + "select pg_advisory_unlock(hashtext('jyotisha_schema_migrations'))", + ); + } catch { + // The connection may already be unusable; closing it still releases the lock. + } + } + await client.end().catch(() => {}); + } +} + +function requireSchemaDatabaseUrl(env) { + const value = env.SCHEMA_DATABASE_URL?.trim(); + if (!value) throw new SafeMigrationError("SCHEMA_DATABASE_URL is required"); + if (!value.startsWith("postgresql://")) { + throw new SafeMigrationError("SCHEMA_DATABASE_URL must be a PostgreSQL URL"); + } + return value; +} + +function safeErrorMessage(error) { + return error instanceof SafeMigrationError + ? error.message + : "database migration failed"; +} + +const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; + +if (invokedPath === import.meta.url) { + const defaultDirectory = resolve( + dirname(fileURLToPath(import.meta.url)), + "../db/migrations", + ); + try { + const status = await runMigrations({ + connectionString: requireSchemaDatabaseUrl(process.env), + migrationsDirectory: + process.env.MIGRATIONS_DIRECTORY?.trim() || defaultDirectory, + check: process.argv.slice(2).includes("--check"), + }); + process.exitCode = status; + } catch (error) { + console.error(safeErrorMessage(error)); + process.exitCode = 1; + } +} diff --git a/frontend/scripts/staging-image-manifest.mjs b/frontend/scripts/staging-image-manifest.mjs new file mode 100644 index 00000000..bb27f13d --- /dev/null +++ b/frontend/scripts/staging-image-manifest.mjs @@ -0,0 +1,74 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const shaPattern = /^[0-9a-f]{40}$/; +const digestPattern = /^sha256:[0-9a-f]{64}$/; +const expectedKeys = ["git_sha", "api_digest", "web_digest"]; + +export function parseStagingImageManifest(text, expectedSha) { + if (!shaPattern.test(expectedSha)) { + throw new Error("invalid expected staging revision"); + } + + const lines = text.endsWith("\n") ? text.slice(0, -1).split("\n") : text.split("\n"); + if (lines.length !== expectedKeys.length) { + throw new Error("invalid staging image manifest"); + } + + const values = new Map(); + for (const line of lines) { + const separator = line.indexOf("="); + if (separator <= 0) throw new Error("invalid staging image manifest"); + const key = line.slice(0, separator); + const value = line.slice(separator + 1); + if (!expectedKeys.includes(key) || values.has(key)) { + throw new Error("invalid staging image manifest"); + } + values.set(key, value); + } + + if (values.get("git_sha") !== expectedSha) { + throw new Error("staging image manifest revision mismatch"); + } + for (const key of ["api_digest", "web_digest"]) { + if (!digestPattern.test(values.get(key) ?? "")) { + throw new Error("invalid staging image digest"); + } + } + + return { + gitSha: expectedSha, + apiDigest: values.get("api_digest"), + webDigest: values.get("web_digest"), + apiImage: `ghcr.io/jesse-ux/jyotisha-api@${values.get("api_digest")}`, + webImage: `ghcr.io/jesse-ux/jyotisha-web@${values.get("web_digest")}`, + }; +} + +const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; + +if (invokedPath === import.meta.url) { + try { + const [manifestPath, expectedSha] = process.argv.slice(2); + if (!manifestPath || !expectedSha) { + throw new Error("manifest path and expected revision are required"); + } + const manifest = parseStagingImageManifest( + await readFile(manifestPath, "utf8"), + expectedSha, + ); + process.stdout.write( + [ + `git_sha=${manifest.gitSha}`, + `api_image=${manifest.apiImage}`, + `web_image=${manifest.webImage}`, + ].join("\n") + "\n", + ); + } catch { + console.error("invalid staging image manifest"); + process.exitCode = 1; + } +} diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 0a8daad6..e4da60b5 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -95,7 +95,7 @@ export async function PATCH(request: Request) { } const payload = await request.json().catch(() => null) as ProfilePatchPayload | null; - if (!payload || typeof payload !== "object") { + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { return NextResponse.json({ error: "账户资料格式不正确" }, { status: 400 }); } diff --git a/frontend/src/app/api/chart-profiles/[id]/route.ts b/frontend/src/app/api/chart-profiles/[id]/route.ts index bc49ae14..2acce5c1 100644 --- a/frontend/src/app/api/chart-profiles/[id]/route.ts +++ b/frontend/src/app/api/chart-profiles/[id]/route.ts @@ -17,14 +17,17 @@ export async function DELETE(_request: Request, context: RouteContext) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); - const { error } = await supabase + const { count, error } = await supabase .from("chart_profiles") - .delete() + .delete({ count: "exact" }) .eq("id", id) .eq("user_id", user.id) .eq("role", "other"); if (error) throw error; + if (count !== 1) { + return NextResponse.json({ error: "星盘不存在或无权删除" }, { status: 404 }); + } return NextResponse.json({ ok: true }); } catch (error) { if (isSupabaseConfigurationError(error)) { diff --git a/frontend/src/app/api/chart-profiles/route.ts b/frontend/src/app/api/chart-profiles/route.ts index e537af1d..a9f37060 100644 --- a/frontend/src/app/api/chart-profiles/route.ts +++ b/frontend/src/app/api/chart-profiles/route.ts @@ -41,7 +41,7 @@ export async function POST(request: Request) { if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); const body = await request.json().catch(() => null) as ChartProfilePayload | null; - if (!body?.profile || typeof body.profile !== "object") { + if (!body?.profile || typeof body.profile !== "object" || Array.isArray(body.profile)) { return NextResponse.json({ error: "星盘资料格式不正确" }, { status: 400 }); } const role = body.role === "self" ? "self" : "other"; @@ -70,16 +70,9 @@ export async function POST(request: Request) { .single(); ({ data, error } = await query); } else { - const record = { - ...(body.id ? { id: body.id } : {}), - user_id: user.id, - role, - profile: body.profile, - updated_at: updatedAt, - }; ({ data, error } = await supabase .from("chart_profiles") - .upsert(record, { onConflict: "id" }) + .insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt }) .select("id, role, profile, updated_at") .single()); } diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts new file mode 100644 index 00000000..5c0919f7 --- /dev/null +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -0,0 +1,28 @@ +import { NextResponse } from "next/server"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; + +type RouteContext = { params: Promise<{ id: string }> }; + +export async function DELETE(_request: Request, context: RouteContext) { + try { + const { id } = await context.params; + const supabase = await createServerSupabaseClient(); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + + const { count, error } = await supabase + .from("chat_sessions") + .delete({ count: "exact" }) + .eq("id", id) + .eq("user_id", user.id); + if (error) throw error; + if (count !== 1) return NextResponse.json({ error: "聊天记录不存在或无权删除" }, { status: 404 }); + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: error instanceof Error ? error.message : "删除聊天记录失败" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/synastry/route.ts b/frontend/src/app/api/synastry/route.ts index 39c4a1ee..9396a731 100644 --- a/frontend/src/app/api/synastry/route.ts +++ b/frontend/src/app/api/synastry/route.ts @@ -11,6 +11,8 @@ type Profile = { districtCode?: string; }; +type RelationshipType = "romance" | "business" | "family" | "general"; + const apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200"; const china = chinaLocations.country; @@ -102,6 +104,30 @@ function relationshipReport(synastry: Record, selfD9: Record, division: string, planet: string) { + const result = varga.result && typeof varga.result === "object" ? varga.result as Record : {}; + const chart = result[division] && typeof result[division] === "object" ? result[division] as Record : {}; + const planets = chart.planets && typeof chart.planets === "object" ? chart.planets as Record : {}; + return planetSign(planet === "Ascendant" ? chart.ascendant : planets[planet]); +} + +function businessReport(selfVargas: Record, partnerVargas: Record) { + return { + status: "partial_evidence", + scoreBand: "not_scored", + headline: "已完成基础合作结构筛查;这不是合作成败、收益或契约保证。", + strengths: [ + `D10 事业轴:本人 ${vargaPlanetSign(selfVargas, "D10_Dasamsa", "Ascendant")} / 对方 ${vargaPlanetSign(partnerVargas, "D10_Dasamsa", "Ascendant")}`, + `D2 财富轴:本人 Moon ${vargaPlanetSign(selfVargas, "D2_Hora", "Moon")} / 对方 Moon ${vargaPlanetSign(partnerVargas, "D2_Hora", "Moon")}`, + `D11 收益轴:本人 Sun ${vargaPlanetSign(selfVargas, "D11_Rudramsa", "Sun")} / 对方 Sun ${vargaPlanetSign(partnerVargas, "D11_Rudramsa", "Sun")}`, + ], + risks: [ + "尚未完成 A10、功能吉凶、双方 Vimshottari + Narayana、Shadbala/AV 与外部数值一致性,不得据此断言合作结果或精确时点。", + ], + nextEvidence: ["A10", "功能吉凶", "双方 Vimshottari + Narayana", "D10/D2/D11 原始度数与外部校验"], + }; +} + async function postPython(path: string, body: unknown) { const response = await fetch(`${apiBase}${path}`, { method: "POST", @@ -118,12 +144,37 @@ async function postPython(path: string, body: unknown) { export async function POST(request: Request) { try { - const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile } | null; + const body = await request.json().catch(() => null) as { selfProfile?: Profile; partnerProfile?: Profile; relationshipType?: RelationshipType } | null; if (!body?.selfProfile || !body.partnerProfile) { return NextResponse.json({ error: "请提供双方星盘资料" }, { status: 400 }); } const selfChart = await postPython("/api/chart", birthPayload(body.selfProfile)); const partnerChart = await postPython("/api/chart", birthPayload(body.partnerProfile)); + const relationshipType = body.relationshipType === "business" || body.relationshipType === "family" || body.relationshipType === "general" + ? body.relationshipType + : "romance"; + if (relationshipType === "business") { + const [selfVargas, partnerVargas] = await Promise.all([ + postPython("/api/varga_full", { ...birthPayload(body.selfProfile), planets: selfChart.planets, ascendant: selfChart.ascendant, divisions: ["D2", "D10", "D11"] }), + postPython("/api/varga_full", { ...birthPayload(body.partnerProfile), planets: partnerChart.planets, ascendant: partnerChart.ascendant, divisions: ["D2", "D10", "D11"] }), + ]); + return NextResponse.json({ + status: "ok", + relationshipType, + claimStatus: "partial", + method: "d2_d10_d11_business_screening_partial", + evidenceLayers: ["d2_hora", "d10_dashamsa", "d11_ekadashamsa"], + blockedLayers: ["A10", "functional_benefic_malefic", "vimshottari_narayana", "shadbala_ashtakavarga", "external_engine_parity"], + relationshipReport: businessReport(selfVargas, partnerVargas), + }); + } + if (relationshipType !== "romance") { + return NextResponse.json({ + status: "blocked", + relationshipType, + message: "该关系类型尚无可验证的专用合盘计算合同;已保留问题草稿。", + }); + } const selfD9 = await postPython("/api/varga_full", { ...birthPayload(body.selfProfile), planets: selfChart.planets, diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 2c2c2c28..c07a0eab 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -208,6 +208,7 @@ button:disabled { cursor: default; opacity: .45; } } @media (max-width: 767px) { + .conversation.is-empty { display: block; } .brand-row { padding: 0 4px; } .brand-mark { width: 26px; height: 26px; } .session-nav { overflow: hidden; } @@ -706,3 +707,5 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .auth-panel h1 { font-size: var(--type-display-sm); } .admin-section { padding: var(--space-5); } } +.session-delete-overlay { z-index: 100; } +.session-delete-confirmation p { margin: 0; color: var(--color-ink-secondary); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ab9a15c6..1959c3cd 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -89,6 +89,7 @@ type ChartLibraryRecord = { profile: Profile; updatedAt: number; }; +type SynastryRelationshipType = "romance" | "business" | "family" | "general"; type ChartLibraryApiRecord = { id: string; role: "self" | "other"; @@ -337,13 +338,12 @@ async function saveCloudChartProfile(record: ChartLibraryRecord) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - id: record.role === "self" ? undefined : record.id, role: record.role, profile: record.profile, }), }); - if (!response.ok) throw new Error("cloud_chart_profile_save_failed"); - const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord } | null; + const payload = await response.json().catch(() => null) as { profile?: ChartLibraryApiRecord; error?: string } | null; + if (!response.ok) throw new Error(payload?.error || "cloud_chart_profile_save_failed"); return payload?.profile ? normalizeChartLibraryApiRecord(payload.profile) : record; } @@ -356,12 +356,18 @@ function profilePlaceLabel(profile: Profile) { return selectedBirthPlace(profile)?.label || "地点未完整"; } -function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile) { +function buildSynastryQuestion(selfProfile: Profile, partnerProfile: Profile, relationshipType: SynastryRelationshipType) { + const relationshipLabel = relationshipType === "business" ? "商业合作" : relationshipType === "family" ? "亲友/家庭" : relationshipType === "general" ? "其他关系" : "婚恋"; + const evidenceRequest = relationshipType === "business" + ? "请先说明 D2/D10/D11 已用层与 A10、双方 Dasha/Narayana、功能吉凶等缺失层;不得给出合作成败、收益保证或精确时点。" + : relationshipType === "romance" + ? "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。" + : "请先说明当前缺少专用合盘计算合同,只基于可验证资料提出需要补充的现实关系信息,不作确定性判断。"; return [ - `请用印度占星合盘分析我和${partnerProfile.name || "对方"}的关系。`, + `请用印度占星分析我和${partnerProfile.name || "对方"}的${relationshipLabel}关系。`, `我的资料:${selfProfile.name || "本人"},${selfProfile.date} ${selfProfile.time},${profilePlaceLabel(selfProfile)}。`, `对方资料:${partnerProfile.name || "对方"},${partnerProfile.date} ${partnerProfile.time},${profilePlaceLabel(partnerProfile)}。`, - "请先说明会使用哪些证据层,再分析关系模式、冲突点、适合发展的方式和需要谨慎的时间窗口。", + evidenceRequest, ].join("\n"); } @@ -402,6 +408,13 @@ function missingProfileStep(profile: Profile): OnboardingStep | null { return null; } +function missingOtherProfileStep(profile: Profile): "name" | "birth" | "place" | null { + if (!profile.name.trim()) return "name"; + if (!isBirthTimeDraftReady(profile)) return "birth"; + if (!selectedBirthPlace(profile)) return "place"; + return null; +} + function birthQuestion(name: string) { return `${name},你好。接下来请告诉我出生日期,以及你对出生时间知道到什么程度。不确定也没关系,我不会要求你猜一个具体时间。`; } @@ -662,6 +675,8 @@ export default function Home() { const [activeAccountDialog, setActiveAccountDialog] = useState(null); const [chartLibrary, setChartLibrary] = useState([]); const [chartLibraryOpen, setChartLibraryOpen] = useState(false); + const [synastryRelationshipType, setSynastryRelationshipType] = useState("romance"); + const [synastryPendingId, setSynastryPendingId] = useState(null); const [otherProfileDraft, setOtherProfileDraft] = useState(emptyProfile); const [synastryReportCard, setSynastryReportCard] = useState(null); const [synastryHistory, setSynastryHistory] = useState([]); @@ -680,6 +695,7 @@ export default function Home() { const [archivedSessionIds, setArchivedSessionIds] = useState([]); const [showArchivedSessions, setShowArchivedSessions] = useState(false); const [sessionMenuId, setSessionMenuId] = useState(null); + const [pendingSessionDeletion, setPendingSessionDeletion] = useState(null); const [modelCatalog, setModelCatalog] = useState(null); const [activeSessionId, setActiveSessionId] = useState(""); const [draft, setDraft] = useState(""); @@ -795,12 +811,8 @@ export default function Home() { setSynastryHistory(readSynastryHistory(accountId)); void fetchCloudChartLibrary() .then((cloudLibrary) => { - setChartLibrary((current) => { - const otherById = new Map([ - ...current.filter((record) => record.role === "other").map((record) => [record.id, record] as const), - ...cloudLibrary.filter((record) => record.role === "other").map((record) => [record.id, record] as const), - ]); - const next = upsertSelfChart([...otherById.values()], profile); + setChartLibrary(() => { + const next = upsertSelfChart(cloudLibrary.filter((record) => record.role !== "self"), profile); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); @@ -1128,7 +1140,7 @@ export default function Home() { return () => { cancelled = true; }; - }, [hydrated, profile.date, profile.time, profile.birthTimeStatus, profile.provinceCode, profile.cityCode, profileComplete]); + }, [hydrated, profile, profileComplete]); useEffect(() => { if (!hydrated || !profileComplete) return; @@ -1234,7 +1246,7 @@ export default function Home() { } async function deleteSession(session: ChatSession) { - if (!account || !window.confirm(`删除“${session.title}”?此操作不可恢复。`)) return; + if (!account) return; const previousSessions = sessions; const nextSessions = sessions.filter((item) => item.id !== session.id); setSessions(nextSessions); @@ -1242,9 +1254,9 @@ export default function Home() { setArchivedSessionIds((current) => current.filter((id) => id !== session.id)); if (activeSessionId === session.id) setActiveSessionId(nextSessions[0]?.id ?? ""); try { - const supabase = createBrowserSupabaseClient(); - const { error } = await supabase.from("chat_sessions").delete().eq("id", session.id).eq("user_id", account.user.id); - if (error) throw error; + const response = await fetch(`/api/sessions/${encodeURIComponent(session.id)}`, { method: "DELETE" }); + const payload = await response.json().catch(() => null) as { error?: string } | null; + if (!response.ok) throw new Error(payload?.error || "删除聊天记录失败"); } catch (caught) { setSessions(previousSessions); setComposerNotice(caught instanceof Error ? `删除失败:${caught.message}` : "删除失败"); @@ -1256,8 +1268,12 @@ export default function Home() { } function toggleArchivedSession(sessionId: string) { - setArchivedSessionIds((current) => current.includes(sessionId) ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); - if (activeSessionId === sessionId) setActiveSessionId(visibleSessions.find((session) => session.id !== sessionId)?.id ?? ""); + const restoring = archivedSessionIds.includes(sessionId); + setArchivedSessionIds((current) => restoring ? current.filter((id) => id !== sessionId) : [sessionId, ...current]); + if (!restoring && activeSessionId === sessionId) { + setActiveSessionId(visibleSessions.find((session) => session.id !== sessionId)?.id ?? ""); + } + setComposerNotice(restoring ? "已恢复到聊天记录。" : "已归档,可在左侧归档中恢复。"); } async function shareSession(session: ChatSession) { @@ -1431,7 +1447,7 @@ export default function Home() { async function saveOtherChart(event: FormEvent) { event.preventDefault(); const nextProfile = { ...otherProfileDraft, name: otherProfileDraft.name.trim() }; - if (missingProfileStep(nextProfile)) { + if (missingOtherProfileStep(nextProfile)) { setAccountError("请补全其他星盘的称呼、出生时间和出生地点。"); return; } @@ -1442,10 +1458,13 @@ export default function Home() { profile: nextProfile, updatedAt: timestamp(), }; + let cloudSaved = false; try { record = await saveCloudChartProfile(record); + cloudSaved = true; } catch { - // Keep local chart library usable when cloud sync is unavailable. + setProfileNotice("已保存到本地星盘库;云端同步失败,稍后会继续使用本地记录。"); + setAccountError(""); } setChartLibrary((current) => { const next = [...upsertSelfChart(current, profile), record]; @@ -1453,20 +1472,30 @@ export default function Home() { return next; }); setOtherProfileDraft(emptyProfile); - setAccountError(""); - setProfileNotice("已添加到星盘库。"); + if (cloudSaved) { + setAccountError(""); + setProfileNotice("已保存到云端星盘库。请选择关系类型后点击“用于合盘”。"); + } } - function deleteOtherChart(recordId: string) { + async function deleteOtherChart(recordId: string) { if (!accountId) return; - void deleteCloudChartProfile(recordId).catch(() => { - // Local deletion should not be blocked by temporary cloud sync failures. - }); + let cloudDeleted = false; + try { + await deleteCloudChartProfile(recordId); + cloudDeleted = true; + } catch { + setAccountError(""); + } setChartLibrary((current) => { const next = current.filter((record) => record.id !== recordId || record.role === "self"); localStorage.setItem(chartLibraryStorageKey(accountId), JSON.stringify(next)); return next; }); + setAccountError(""); + setProfileNotice(cloudDeleted + ? "已从云端星盘库删除。" + : "已从本地星盘库删除;云端同步失败,稍后云端可能仍显示旧记录。"); } async function makeDefaultChart(record: ChartLibraryRecord) { @@ -1728,21 +1757,27 @@ export default function Home() { ); } - async function draftSynastryQuestionFromChart(record: ChartLibraryRecord) { + async function draftSynastryQuestionFromChart(record: ChartLibraryRecord, relationshipType: SynastryRelationshipType) { if (record.role !== "other") return; - const baseQuestion = buildSynastryQuestion(profile, record.profile); + if (synastryPendingId) return; + const baseQuestion = buildSynastryQuestion(profile, record.profile, relationshipType); + setSynastryPendingId(record.id); + setComposerNotice("正在计算基础合盘证据,请稍候。"); try { const response = await fetch("/api/synastry", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile }), + body: JSON.stringify({ selfProfile: profile, partnerProfile: record.profile, relationshipType }), }); - const payload = await response.json().catch(() => null) as { status?: string; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null; + const payload = await response.json().catch(() => null) as { status?: string; claimStatus?: string; blockedLayers?: string[]; evidenceLayers?: string[]; synastry?: { total_score?: number; max_score?: number; assessment?: string }; relationshipReport?: { headline?: string; scoreBand?: string; strengths?: string[]; risks?: string[]; nextEvidence?: string[] } } | null; if (response.ok && payload?.status === "ok") { const score = payload.synastry?.total_score; const max = payload.synastry?.max_score; const assessment = payload.synastry?.assessment; const layers = (payload.evidenceLayers || []).join(" / ") || "Ashtakoot / Moon / D9"; + const evidenceSummary = relationshipType === "business" + ? `已完成基础商业合作证据筛查:${layers};声明状态:${payload.claimStatus || "partial"};未用层:${(payload.blockedLayers || []).join(" / ") || "A10 / 双方 Dasha-Narayana / 功能吉凶"}。请勿将其表述为合作保证或精确时点。` + : `已计算基础合盘证据:${layers};Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`; const reportCard: SynastryReportCard = { id: `${record.id}-${Date.now()}`, partnerName: record.profile.name || "对方", @@ -1775,16 +1810,18 @@ export default function Home() { chooseSuggestedQuestion([ baseQuestion, "", - `已计算基础合盘证据:${layers};Ashtakoot ${score ?? "?"}/${max ?? "?"},初步评级:${assessment || "待解释"}。请基于这个证据包继续分析。`, + evidenceSummary, payload.relationshipReport?.headline ? `结构化摘要:${payload.relationshipReport.headline}` : "", - ].join("\n"), "marriage"); + ].join("\n"), relationshipType === "business" ? "career" : "marriage"); } else { - chooseSuggestedQuestion(baseQuestion, "marriage"); + chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); setComposerNotice(payload?.status === "blocked" ? "合盘计算暂时不可用,已先生成问题草稿。" : "已生成合盘问题草稿。"); } } catch { - chooseSuggestedQuestion(baseQuestion, "marriage"); + chooseSuggestedQuestion(baseQuestion, relationshipType === "business" ? "career" : "marriage"); setComposerNotice("合盘计算暂时不可用,已先生成问题草稿。"); + } finally { + setSynastryPendingId(null); } closeAccountDialog(); } @@ -2276,7 +2313,7 @@ export default function Home() { onToggleArchived: toggleArchivedSession, onDelete: (sessionId) => { const session = sessions.find((candidate) => candidate.id === sessionId); - if (session) void deleteSession(session); + if (session) setPendingSessionDeletion(session); }, }} onAccountMenuOpenChange={setAccountMenuOpen} @@ -2286,6 +2323,22 @@ export default function Home() { onOpenRedeem={() => openAccountDialog("redeem")} onOpenLogout={() => openAccountDialog("logout")} /> + {pendingSessionDeletion ? ( +
setPendingSessionDeletion(null)}> +
event.stopPropagation()}> +

删除聊天记录?

+

“{pendingSessionDeletion.title}”将被永久删除,无法恢复。

+
+ + +
+
+
+ ) : null}
@@ -2578,7 +2631,13 @@ export default function Home() { {record.profile.date} {record.profile.time} · {profilePlaceLabel(record.profile)}
- + +
diff --git a/frontend/src/hooks/use-birth-time-guided-journey.ts b/frontend/src/hooks/use-birth-time-guided-journey.ts index 81a08092..8fac6fbb 100644 --- a/frontend/src/hooks/use-birth-time-guided-journey.ts +++ b/frontend/src/hooks/use-birth-time-guided-journey.ts @@ -20,6 +20,7 @@ import { saveGuidedBirthTimeCandidate, } from "@/lib/birth-time-guided-client"; import { confirmReviewedBirthTimeDraft } from "@/lib/birth-time-guided-draft-confirmation"; +import { birthTimeUserError } from "@/lib/birth-time-user-error"; import { claimMutation, createStableActionIdentityRegistry, @@ -112,7 +113,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG publish: onJourney, })) latest.current = turn; }).catch((caught: unknown) => { - setError(caught instanceof Error ? caught.message : "当前步骤暂时无法完成,请重试。"); + setError(birthTimeUserError(caught)); }).finally(() => { release(); setPending(false); @@ -198,7 +199,7 @@ export function useBirthTimeGuidedJourney(input: GuidedJourneyInput): BirthTimeG : completeGuidedBirthTimeCandidate({ caseId: turn.caseId, resultId, time }); void completion .then(() => onCandidateComplete(turn, time)) - .catch((caught) => setError(caught instanceof Error ? caught.message : "候选时间暂时无法保存")) + .catch((caught) => setError(birthTimeUserError(caught))) .finally(() => { release(); setPending(false); diff --git a/frontend/src/lib/birth-time-dynamic-token.ts b/frontend/src/lib/birth-time-dynamic-token.ts new file mode 100644 index 00000000..782d2850 --- /dev/null +++ b/frontend/src/lib/birth-time-dynamic-token.ts @@ -0,0 +1,14 @@ +import { createHash } from "node:crypto"; + +export function resolveDynamicRectificationToken( + configuredToken: string | undefined, + serviceRoleKey: string | undefined, +): string | null { + const configured = configuredToken?.trim(); + if (configured) return configured; + const serviceRole = serviceRoleKey?.trim(); + if (!serviceRole) return null; + return createHash("sha256") + .update(`jyotisha-dynamic-rectification-v1:${serviceRole}`) + .digest("hex"); +} diff --git a/frontend/src/lib/birth-time-journey-engine.ts b/frontend/src/lib/birth-time-journey-engine.ts index 7ced28b5..e43ac2e9 100644 --- a/frontend/src/lib/birth-time-journey-engine.ts +++ b/frontend/src/lib/birth-time-journey-engine.ts @@ -5,6 +5,7 @@ import { createJourneyEngineWire, } from "./birth-time-journey-engine-model.ts"; import type { BirthTimeJourneyEngine } from "./birth-time-journey-service.ts"; +import { resolveDynamicRectificationToken } from "./birth-time-dynamic-token.ts"; export { BirthTimeJourneyEngineConfigurationError, @@ -16,7 +17,10 @@ export function createJyotishBirthTimeJourneyEngine( ): BirthTimeJourneyEngine { return createJourneyEngineMethods(createJourneyEngineWire({ apiBase, - dynamicToken: process.env.JYOTISH_DYNAMIC_RECTIFICATION_TOKEN ?? null, + dynamicToken: resolveDynamicRectificationToken( + process.env.JYOTISH_DYNAMIC_RECTIFICATION_TOKEN, + process.env.SUPABASE_SERVICE_ROLE_KEY, + ), fetchImpl: fetch, })); } diff --git a/frontend/src/lib/birth-time-user-error.ts b/frontend/src/lib/birth-time-user-error.ts new file mode 100644 index 00000000..3e54aa69 --- /dev/null +++ b/frontend/src/lib/birth-time-user-error.ts @@ -0,0 +1,9 @@ +const unsafeImplementationMessage = /expected pattern|failed to fetch|networkerror|load failed|domexception|syntaxerror/i; + +export function birthTimeUserError(error: unknown): string { + const message = error instanceof Error ? error.message.trim() : ""; + if (!message || unsafeImplementationMessage.test(message) || !/[\u3400-\u9fff]/u.test(message)) { + return "候选时间暂时无法保存,请检查网络后重试。"; + } + return message; +} diff --git a/frontend/src/lib/db/client.ts b/frontend/src/lib/db/client.ts new file mode 100644 index 00000000..b144f4d6 --- /dev/null +++ b/frontend/src/lib/db/client.ts @@ -0,0 +1,18 @@ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; + +export type DomainDatabase = { pool: Pool; db: NodePgDatabase }; + +export function createDomainDatabase( + connectionString: string, + maxConnections = 5, +): DomainDatabase { + const pool = new Pool({ + connectionString, + max: maxConnections, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + application_name: "jyotisha-web", + }); + return { pool, db: drizzle(pool) }; +} diff --git a/frontend/src/lib/db/config.ts b/frontend/src/lib/db/config.ts new file mode 100644 index 00000000..354abc2b --- /dev/null +++ b/frontend/src/lib/db/config.ts @@ -0,0 +1,16 @@ +export type DatabaseUrlKey = + | "IDENTITY_DATABASE_URL" + | "APP_DATABASE_URL" + | "ADMIN_DATABASE_URL"; + +export function readDatabaseUrl( + env: NodeJS.ProcessEnv, + key: DatabaseUrlKey, +): string { + const value = env[key]?.trim(); + if (!value) throw new Error(`${key} is required`); + if (!value.startsWith("postgresql://")) { + throw new Error(`${key} must be a PostgreSQL URL`); + } + return value; +} diff --git a/frontend/supabase/migrations/20260718104000_chart_profiles_upsert_id_grant.sql b/frontend/supabase/migrations/20260718104000_chart_profiles_upsert_id_grant.sql new file mode 100644 index 00000000..c63b9180 --- /dev/null +++ b/frontend/supabase/migrations/20260718104000_chart_profiles_upsert_id_grant.sql @@ -0,0 +1,6 @@ +begin; + +-- PostgREST upsert may include the unchanged conflict key in its update set. +grant update (id, role, profile, updated_at) on table public.chart_profiles to authenticated; + +commit; diff --git a/frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql b/frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql new file mode 100644 index 00000000..d20a4dfd --- /dev/null +++ b/frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql @@ -0,0 +1,12 @@ +begin; + +drop policy if exists chat_sessions_delete_own on public.chat_sessions; +create policy chat_sessions_delete_own + on public.chat_sessions + for delete + to authenticated + using ((select auth.uid()) = user_id); + +grant delete on table public.chat_sessions to authenticated; + +commit; diff --git a/frontend/tests/birth-time-journey-engine.test.ts b/frontend/tests/birth-time-journey-engine.test.ts index 23c0a124..a5675ab2 100644 --- a/frontend/tests/birth-time-journey-engine.test.ts +++ b/frontend/tests/birth-time-journey-engine.test.ts @@ -9,6 +9,7 @@ import { eventScorePayload, } from "../src/lib/birth-time-journey-engine-model.ts"; import type { JourneyEngineFetch } from "../src/lib/birth-time-journey-engine-model.ts"; +import { resolveDynamicRectificationToken } from "../src/lib/birth-time-dynamic-token.ts"; test("journey engine serializes only stored event-scoring inputs", () => { const payload = eventScorePayload({ @@ -191,6 +192,12 @@ test("missing dynamic token fails both endpoints before fetch", async () => { assert.equal(harness.calls.length, 0); }); +test("dynamic token derives only from a server-side service role fallback", () => { + assert.equal(resolveDynamicRectificationToken("configured-token", "service-role"), "configured-token"); + assert.match(resolveDynamicRectificationToken(undefined, "service-role") ?? "", /^[a-f0-9]{64}$/); + assert.equal(resolveDynamicRectificationToken(undefined, undefined), null); +}); + test("legacy wire calls never receive dynamic authorization", async () => { const calls: { readonly path: string; readonly init: RequestInit }[] = []; const engine = createJourneyEngineMethods(createJourneyEngineWire({ diff --git a/frontend/tests/birth-time-mobile-scroll-contract.test.ts b/frontend/tests/birth-time-mobile-scroll-contract.test.ts new file mode 100644 index 00000000..a60483fb --- /dev/null +++ b/frontend/tests/birth-time-mobile-scroll-contract.test.ts @@ -0,0 +1,10 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const css = readFileSync(new URL("../src/app/globals.css", import.meta.url), "utf8"); + +test("mobile rectification welcome content starts at the scroll origin", () => { + assert.match(css, /@media \(max-width: 767px\)[\s\S]*?\.conversation\.is-empty\s*\{[^}]*display:\s*block/); + assert.match(css, /\.conversation\s*\{[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/); +}); diff --git a/frontend/tests/birth-time-user-errors.test.ts b/frontend/tests/birth-time-user-errors.test.ts new file mode 100644 index 00000000..fd611a84 --- /dev/null +++ b/frontend/tests/birth-time-user-errors.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { birthTimeUserError } from "../src/lib/birth-time-user-error.ts"; + +test("birth-time errors never expose browser implementation messages", () => { + assert.equal( + birthTimeUserError(new DOMException("The string did not match the expected pattern.")), + "候选时间暂时无法保存,请检查网络后重试。", + ); +}); + +test("birth-time errors preserve a safe server message", () => { + assert.equal(birthTimeUserError(new Error("候选结果已变化")), "候选结果已变化"); +}); + +test("all guided journey mutations normalize implementation errors", () => { + const source = readFileSync(new URL("../src/hooks/use-birth-time-guided-journey.ts", import.meta.url), "utf8"); + assert.equal((source.match(/setError\(birthTimeUserError\(caught\)\)/g) ?? []).length, 2); +}); diff --git a/frontend/tests/chart-library-other-profile.test.ts b/frontend/tests/chart-library-other-profile.test.ts new file mode 100644 index 00000000..1bfb59a4 --- /dev/null +++ b/frontend/tests/chart-library-other-profile.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const source = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); +const route = readFileSync(new URL("../src/app/api/chart-profiles/route.ts", import.meta.url), "utf8"); + +test("other chart saves do not require the owner's rectification state", () => { + assert.match(source, /function missingOtherProfileStep\(profile: Profile\)/); + assert.match(source, /if \(missingOtherProfileStep\(nextProfile\)\)/); + assert.doesNotMatch(source, /saveOtherChart[\s\S]{0,500}missingProfileStep\(nextProfile\)/); +}); + +test("other chart save falls back to local library when cloud sync fails", () => { + assert.match(source, /let cloudSaved = false/); + assert.match(source, /record = await saveCloudChartProfile\(record\);[\s\S]{0,120}cloudSaved = true/); + assert.match(source, /catch\s*\{[\s\S]{0,300}已保存到本地星盘库;云端同步失败/); + assert.match(source, /localStorage\.setItem\(chartLibraryStorageKey\(accountId\), JSON\.stringify\(next\)\)/); + assert.match(source, /if \(cloudSaved\)[\s\S]{0,180}已保存到云端星盘库/); + assert.match(source, /async function deleteOtherChart[\s\S]{0,500}let cloudDeleted = false/); + assert.match(source, /async function deleteOtherChart[\s\S]{0,500}await deleteCloudChartProfile\(recordId\)/); + assert.match(source, /async function deleteOtherChart[\s\S]{0,900}已从本地星盘库删除;云端同步失败/); + assert.doesNotMatch(source, /deleteOtherChart[\s\S]{0,500}return;\s*}\s*setChartLibrary/); +}); + +test("adding another chart waits for the user to choose a relationship type", () => { + assert.doesNotMatch(source, /async function saveOtherChart[\s\S]{0,1400}await draftSynastryQuestionFromChart\(/); + assert.match(source, /请选择关系类型后点击“用于合盘”。/); + assert.match(source, /用于合盘/); + assert.match(source, /\/api\/synastry/); +}); + +test("a successful cloud read replaces stale local other charts", () => { + assert.match( + source, + /fetchCloudChartLibrary\(\)[\s\S]{0,800}upsertSelfChart\(cloudLibrary\.filter\(\(record\) => record\.role !== "self"\), profile\)/, + ); + assert.doesNotMatch(source, /fetchCloudChartLibrary\(\)[\s\S]{0,800}new Map\(\[[\s\S]{0,500}current\.filter\(\(record\) => record\.role === "other"\)/); +}); + +test("other chart creation lets the database create its UUID", () => { + assert.match(route, /\.insert\(\{ user_id: user\.id, role, profile: body\.profile, updated_at: updatedAt \}\)/); + assert.doesNotMatch(route, /\.upsert\(record, \{ onConflict: "id" \}\)/); + assert.doesNotMatch(source, /id: record\.role === "self" \? undefined : record\.id/); +}); + +test("cloud save failures preserve the server error message", () => { + assert.match(source, /const payload = await response\.json\(\)\.catch\(\(\) => null\) as \{ error\?: string \} \| null;/); + assert.match(source, /throw new Error\(payload\?\.error \|\| "cloud_chart_profile_save_failed"\);/); +}); + +test("synastry selection exposes a pending state while the evidence packet is computed", () => { + assert.match(source, /const \[synastryPendingId, setSynastryPendingId\] = useState\(null\);/); + assert.match(source, /setSynastryPendingId\(record\.id\);/); + assert.match(source, /finally \{\s*setSynastryPendingId\(null\);\s*\}/); + assert.match(source, /disabled=\{synastryPendingId !== null\}/); + assert.match(source, /synastryPendingId === record\.id \? "正在计算合盘\.\.\." : "用于合盘"/); +}); + +test("relationship intent selects domain-specific evidence instead of treating every pairing as romance", () => { + const route = readFileSync(new URL("../src/app/api/synastry/route.ts", import.meta.url), "utf8"); + assert.match(source, /商业合作/); + assert.match(source, /亲友\/家庭/); + assert.match(source, /其他关系/); + assert.match(source, /relationshipType: SynastryRelationshipType/); + assert.match(source, /body: JSON\.stringify\(\{ selfProfile: profile, partnerProfile: record\.profile, relationshipType \}\)/); + assert.match(route, /relationshipType === "business"/); + assert.match(route, /divisions: \["D2", "D10", "D11"\]/); + assert.match(route, /"D10_Dasamsa"/); + assert.match(route, /"D11_Rudramsa"/); + assert.match(route, /blockedLayers:/); + assert.match(route, /"A10"/); + assert.match(route, /"functional_benefic_malefic"/); + assert.match(route, /"vimshottari_narayana"/); + assert.match(source, /基础商业合作证据筛查/); + assert.match(source, /relationshipType === "business"/); +}); diff --git a/frontend/tests/chart-profile-upsert-grant.test.ts b/frontend/tests/chart-profile-upsert-grant.test.ts new file mode 100644 index 00000000..dd7decaf --- /dev/null +++ b/frontend/tests/chart-profile-upsert-grant.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync( + new URL("../supabase/migrations/20260718104000_chart_profiles_upsert_id_grant.sql", import.meta.url), + "utf8", +); + +test("authenticated chart-profile upserts may update their unchanged conflict id", () => { + assert.match( + migration, + /grant\s+update\s*\(\s*id\s*,\s*role\s*,\s*profile\s*,\s*updated_at\s*\)\s+on\s+table\s+public\.chart_profiles\s+to\s+authenticated/i, + ); +}); diff --git a/frontend/tests/database-backup.test.ts b/frontend/tests/database-backup.test.ts new file mode 100644 index 00000000..fae7d3ff --- /dev/null +++ b/frontend/tests/database-backup.test.ts @@ -0,0 +1,581 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { startPostgresFixture } from "./helpers/postgres-fixture"; + +const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); +const backupScript = join(repositoryRoot, "deploy/backup-staging-postgres.sh"); +const fixtureSecrets = [ + "postgres-test-password", + "schema-owner-test-password", + "identity-runtime-test-password", + "app-runtime-test-password", + "admin-runtime-test-password", + "migration-runner-test-password", + "backup-reader-test-password", + "staging-backup-test-password", +]; +const databaseEnvironment = `POSTGRES_DB=jyotisha +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres-test-password +SCHEMA_OWNER_PASSWORD=schema-owner-test-password +IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password +APP_RUNTIME_PASSWORD=app-runtime-test-password +ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password +MIGRATION_RUNNER_PASSWORD=migration-runner-test-password +BACKUP_READER_PASSWORD=backup-reader-test-password +STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password +SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-test-password@postgres:5432/jyotisha +`; + +function listBackups(directory: string): string[] { + return readdirSync(directory) + .filter((name) => /^jyotisha-staging-\d{8}T\d{6}Z\.dump\.enc$/.test(name)) + .sort(); +} + +function canonicalTemporaryDirectory(prefix: string): string { + return realpathSync(mkdtempSync(join(tmpdir(), prefix))); +} + +function canonicalSharedTemporaryDirectory(prefix: string): string { + return realpathSync(mkdtempSync(join("/tmp", prefix))); +} + +function listDumpArchive(fixture: ReturnType, dump: Buffer): void { + const hostRestore = spawnSync("pg_restore", ["--list"], { + input: dump, + encoding: "utf8", + }); + if (hostRestore.status === 0) return; + + const result = spawnSync( + "docker", + [ + "compose", + "--project-name", + fixture.projectName, + "--env-file", + fixture.databaseEnvFile, + "-f", + "../deploy/docker-compose.postgres.yml", + "-f", + "../deploy/docker-compose.postgres-ci.yml", + "exec", + "-T", + "postgres", + "pg_restore", + "--list", + ], + { cwd: join(repositoryRoot, "frontend"), input: dump, encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); +} + +function createDatabaseEnvironment(): { directory: string; file: string } { + const directory = mkdtempSync(join(tmpdir(), "jyotisha-backup-env-")); + const file = join(directory, "database.env"); + writeFileSync(file, databaseEnvironment, { mode: 0o600 }); + chmodSync(file, 0o600); + return { directory, file }; +} + +function writeCommand(directory: string, name: string, script: string): void { + const path = join(directory, name); + writeFileSync(path, `#!/usr/bin/env bash\nset -eu\n${script}\n`, { mode: 0o700 }); + chmodSync(path, 0o700); +} + +function safeDiskCommand(directory: string, usage = 10): void { + writeCommand( + directory, + "df", + `printf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted on'\nprintf '%s\\n' '/dev/test 1000 100 900 ${usage}% /tmp'`, + ); +} + +function backupEnvironment(commandDirectory: string, extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + return { + ...process.env, + ...extra, + PATH: `${commandDirectory}:${process.env.PATH ?? ""}`, + }; +} + +function runBackup( + databaseEnvFile: string, + backupDirectory: string, + environment: NodeJS.ProcessEnv, + cwd = repositoryRoot, + callerUmask?: string, +) { + const args = callerUmask + ? ["-c", 'umask "$1"; shift; exec bash "$@"', "backup-umask", callerUmask, backupScript, databaseEnvFile, backupDirectory] + : [backupScript, databaseEnvFile, backupDirectory]; + return spawnSync("bash", args, { + cwd, + encoding: "utf8", + env: environment, + }); +} + +function runBackupAsync( + databaseEnvFile: string, + backupDirectory: string, + environment: NodeJS.ProcessEnv, +): Promise<{ status: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn("bash", [backupScript, databaseEnvFile, backupDirectory], { + cwd: repositoryRoot, + env: environment, + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (status) => resolve({ status, stdout, stderr })); + }); +} + +test("staging backups are encrypted, atomic, private, and retain the newest three", () => { + const fixture = startPostgresFixture(); + const backupDirectory = canonicalTemporaryDirectory("jyotisha-staging-backup-"); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-command-")); + const diskUsageCommand = join(commandDirectory, "df"); + const timestamps = [ + "20260720T010101Z", + "20260720T010102Z", + "20260720T010103Z", + "20260720T010104Z", + ]; + + try { + writeFileSync( + diskUsageCommand, + "#!/usr/bin/env bash\nprintf '%s\\n' 'Filesystem 1024-blocks Used Available Capacity Mounted on'\nprintf '%s\\n' '/dev/test 1000 100 900 10% /tmp'\n", + { mode: 0o700 }, + ); + chmodSync(diskUsageCommand, 0o700); + + for (const timestamp of timestamps) { + const result = spawnSync( + "bash", + [backupScript, fixture.databaseEnvFile, backupDirectory], + { + cwd: repositoryRoot, + encoding: "utf8", + env: { + ...process.env, + BACKUP_TIMESTAMP: timestamp, + COMPOSE_PROJECT_NAME: fixture.projectName, + PATH: `${commandDirectory}:${process.env.PATH}`, + }, + }, + ); + assert.equal(result.status, 0, result.stderr); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, new RegExp(fixtureSecrets.join("|"))); + } + + const backups = listBackups(backupDirectory); + assert.deepEqual(backups, [ + "jyotisha-staging-20260720T010102Z.dump.enc", + "jyotisha-staging-20260720T010103Z.dump.enc", + "jyotisha-staging-20260720T010104Z.dump.enc", + ]); + assert.deepEqual( + readdirSync(backupDirectory).filter((name) => name.endsWith(".partial")), + [], + ); + assert.equal(statSync(backupDirectory).mode & 0o777, 0o700); + for (const backup of backups) { + assert.equal(statSync(join(backupDirectory, backup)).mode & 0o777, 0o600); + } + + const encrypted = readFileSync(join(backupDirectory, backups[0])); + const decrypted = spawnSync( + "openssl", + ["enc", "-d", "-aes-256-cbc", "-pbkdf2", "-pass", "env:STAGING_BACKUP_ENCRYPTION_KEY"], + { + input: encrypted, + env: { + ...process.env, + STAGING_BACKUP_ENCRYPTION_KEY: "staging-backup-test-password", + }, + }, + ); + assert.equal(decrypted.status, 0, decrypted.stderr.toString()); + listDumpArchive(fixture, decrypted.stdout); + } finally { + fixture.stop(); + rmSync(backupDirectory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("rejects destructive backup directory aliases and symlink components before mutation", () => { + const root = canonicalSharedTemporaryDirectory("jyotisha-backup-boundary-"); + const environmentFile = createDatabaseEnvironment(); + const target = join(root, "target"); + const sentinel = join(target, "sentinel.txt"); + const originalMode = 0o755; + + try { + mkdirSync(target, { mode: originalMode }); + chmodSync(target, originalMode); + writeFileSync(sentinel, "must remain untouched"); + symlinkSync(target, join(root, "backup-link")); + + for (const directory of [ + "/", + "/tmp/..", + "/tmp//canonical-alias", + "relative-backup", + `${target}/../attempt`, + `${target}/`, + join(root, "backup-link"), + join(root, "backup-link", "nested"), + ]) { + const result = runBackup( + environmentFile.file, + directory, + process.env, + root, + ); + assert.notEqual(result.status, 0); + assert.match( + result.stderr, + /backup directory must be an absolute path without traversal, aliases, or symlinks/, + ); + } + + assert.equal(statSync(target).mode & 0o777, originalMode); + assert.equal(readFileSync(sentinel, "utf8"), "must remain untouched"); + assert.deepEqual(readdirSync(target), ["sentinel.txt"]); + } finally { + rmSync(root, { force: true, recursive: true }); + rmSync(environmentFile.directory, { force: true, recursive: true }); + } +}); + +test("rejects unsafe writable backup parents before creating the target", () => { + const root = canonicalSharedTemporaryDirectory("jyotisha-backup-unsafe-parent-"); + const environmentFile = createDatabaseEnvironment(); + const unsafeParent = join(root, "unsafe-parent"); + const target = join(unsafeParent, "backup"); + const sentinel = join(unsafeParent, "sentinel.txt"); + + try { + mkdirSync(unsafeParent, { mode: 0o700 }); + writeFileSync(sentinel, "must remain untouched"); + chmodSync(unsafeParent, 0o777); + + const result = runBackup(environmentFile.file, target, process.env, root); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /backup directory ancestor must be owned by the current user or root and not group\/world-writable/); + assert.equal(existsSync(target), false); + assert.equal(statSync(unsafeParent).mode & 0o777, 0o777); + assert.equal(readFileSync(sentinel, "utf8"), "must remain untouched"); + } finally { + rmSync(root, { force: true, recursive: true }); + rmSync(environmentFile.directory, { force: true, recursive: true }); + } +}); + +test("rejects a direct canonical sticky shared backup directory before chmod", () => { + const sharedDirectory = realpathSync("/tmp"); + const environmentFile = createDatabaseEnvironment(); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-direct-shared-command-")); + const chmodLog = join(commandDirectory, "chmod.log"); + const originalMode = statSync(sharedDirectory).mode & 0o777; + + try { + writeCommand(commandDirectory, "chmod", "printf '%s' called > \"$CHMOD_LOG\"\nexit 97"); + const result = runBackup( + environmentFile.file, + sharedDirectory, + backupEnvironment(commandDirectory, { CHMOD_LOG: chmodLog }), + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /backup directory ancestor must be owned by the current user or root and not group\/world-writable/); + assert.equal(existsSync(chmodLog), false); + assert.equal(statSync(sharedDirectory).mode & 0o777, originalMode); + } finally { + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("fails safely when a racer inserts a symlink during nested backup path creation", () => { + const root = canonicalSharedTemporaryDirectory("jyotisha-backup-create-race-"); + const raceTarget = canonicalTemporaryDirectory("jyotisha-backup-race-target-"); + const environmentFile = createDatabaseEnvironment(); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-create-race-command-")); + const target = join(root, "first", "second", "backup"); + const originalRootMode = statSync(root).mode & 0o777; + + try { + safeDiskCommand(commandDirectory); + writeCommand(commandDirectory, "docker", "printf '%s' 'race dump payload'"); + writeCommand( + commandDirectory, + "mkdir", + `if [ "$#" -eq 1 ] && [ "$1" = ./first ]; then + ln -s "$RACE_TARGET" ./first +fi +exec /bin/mkdir "$@"`, + ); + const result = runBackup( + environmentFile.file, + target, + backupEnvironment(commandDirectory, { + BACKUP_TIMESTAMP: "20260720T060101Z", + RACE_TARGET: raceTarget, + }), + ); + + assert.notEqual(result.status, 0); + assert.equal(existsSync(join(raceTarget, "second")), false); + assert.equal(existsSync(join(raceTarget, "backup")), false); + assert.equal(statSync(root).mode & 0o777, originalRootMode); + } finally { + rmSync(root, { force: true, recursive: true }); + rmSync(raceTarget, { force: true, recursive: true }); + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("stops absolute pre-scan traversal when a symlink appears after the first missing component", () => { + const root = canonicalSharedTemporaryDirectory("jyotisha-backup-pre-scan-race-"); + const raceTarget = canonicalTemporaryDirectory("jyotisha-backup-pre-scan-target-"); + const environmentFile = createDatabaseEnvironment(); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-pre-scan-command-")); + const preScanHook = join(commandDirectory, "pre-scan-hook.sh"); + const firstComponent = join(root, "first"); + const target = join(firstComponent, "second", "backup"); + const externalBackup = join(raceTarget, "second", "backup"); + const originalRootMode = statSync(root).mode & 0o777; + + try { + mkdirSync(join(raceTarget, "second"), { mode: 0o700 }); + safeDiskCommand(commandDirectory); + writeCommand(commandDirectory, "docker", "printf '%s' 'pre-scan race dump payload'"); + writeFileSync( + preScanHook, + `pre_scan_inject() { + if [ "${"${BASH_COMMAND:-}"}" = 'FIRST_CREATED_COMPONENT_INDEX="$backup_directory_component_index"' ] && [ "${"${backup_directory_component_path:-}"}" = "$PRE_SCAN_FIRST_PATH" ]; then + ln -s "$PRE_SCAN_RACE_TARGET" "$PRE_SCAN_FIRST_PATH" + trap - DEBUG + fi +} +trap pre_scan_inject DEBUG +`, + { mode: 0o700 }, + ); + chmodSync(preScanHook, 0o700); + const result = runBackup( + environmentFile.file, + target, + backupEnvironment(commandDirectory, { + BACKUP_TIMESTAMP: "20260720T060201Z", + BASH_ENV: preScanHook, + PRE_SCAN_FIRST_PATH: firstComponent, + PRE_SCAN_RACE_TARGET: raceTarget, + }), + ); + + assert.notEqual(result.status, 0); + assert.equal(existsSync(externalBackup), false); + assert.equal(statSync(root).mode & 0o777, originalRootMode); + } finally { + rmSync(root, { force: true, recursive: true }); + rmSync(raceTarget, { force: true, recursive: true }); + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("creates every absent backup path component privately despite a permissive caller umask", () => { + const root = canonicalSharedTemporaryDirectory("jyotisha-backup-umask-"); + const environmentFile = createDatabaseEnvironment(); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-umask-command-")); + const components = ["first", "-second", "backup"]; + const target = join(root, ...components); + + try { + safeDiskCommand(commandDirectory); + writeCommand(commandDirectory, "docker", "printf '%s' 'umask dump payload'"); + const result = runBackup( + environmentFile.file, + target, + backupEnvironment(commandDirectory, { BACKUP_TIMESTAMP: "20260720T050101Z" }), + repositoryRoot, + "000", + ); + + assert.equal(result.status, 0, result.stderr); + let createdPath = root; + for (const component of components) { + createdPath = join(createdPath, component); + const created = statSync(createdPath); + assert.equal(created.uid, process.getuid?.()); + assert.equal(created.mode & 0o777, 0o700); + } + } finally { + rmSync(root, { force: true, recursive: true }); + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("same-second backups publish once without overwriting the completed archive", async () => { + const environmentFile = createDatabaseEnvironment(); + const backupDirectory = canonicalTemporaryDirectory("jyotisha-backup-collision-"); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-collision-command-")); + const timestamp = "20260720T020202Z"; + + try { + safeDiskCommand(commandDirectory); + writeCommand( + commandDirectory, + "docker", + "sleep 0.5\nprintf '%s' 'same-second dump payload'", + ); + const environment = backupEnvironment(commandDirectory, { + BACKUP_TIMESTAMP: timestamp, + }); + const results = await Promise.all([ + runBackupAsync(environmentFile.file, backupDirectory, environment), + runBackupAsync(environmentFile.file, backupDirectory, environment), + ]); + + assert.equal(results.filter((result) => result.status === 0).length, 1); + assert.equal(results.filter((result) => result.status !== 0).length, 1); + assert.equal(listBackups(backupDirectory).length, 1); + assert.deepEqual(readdirSync(backupDirectory).filter((name) => name.endsWith(".partial")), []); + assert.deepEqual(readdirSync(backupDirectory).filter((name) => name.endsWith(".lock")), []); + assert.doesNotMatch( + results.map((result) => `${result.stdout}${result.stderr}`).join("\n"), + new RegExp(fixtureSecrets.join("|")), + ); + + const encrypted = readFileSync( + join(backupDirectory, `jyotisha-staging-${timestamp}.dump.enc`), + ); + const decrypted = spawnSync( + "openssl", + ["enc", "-d", "-aes-256-cbc", "-pbkdf2", "-pass", "env:STAGING_BACKUP_ENCRYPTION_KEY"], + { + input: encrypted, + env: { ...process.env, STAGING_BACKUP_ENCRYPTION_KEY: "staging-backup-test-password" }, + }, + ); + assert.equal(decrypted.status, 0, decrypted.stderr.toString()); + assert.equal(decrypted.stdout.toString(), "same-second dump payload"); + } finally { + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(backupDirectory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("find enumeration failures preserve existing backups and do not report completion", () => { + const environmentFile = createDatabaseEnvironment(); + const backupDirectory = canonicalTemporaryDirectory("jyotisha-backup-enumeration-"); + const commandDirectory = mkdtempSync(join(tmpdir(), "jyotisha-backup-enumeration-command-")); + const existingBackups = [ + "jyotisha-staging-20260720T030101Z.dump.enc", + "jyotisha-staging-20260720T030102Z.dump.enc", + "jyotisha-staging-20260720T030103Z.dump.enc", + ]; + + try { + for (const backup of existingBackups) { + writeFileSync(join(backupDirectory, backup), backup, { mode: 0o600 }); + } + writeFileSync(join(backupDirectory, "unrelated.txt"), "retain me"); + safeDiskCommand(commandDirectory); + writeCommand(commandDirectory, "docker", "printf '%s' 'enumeration dump payload'"); + writeCommand(commandDirectory, "find", "exit 91"); + const result = runBackup( + environmentFile.file, + backupDirectory, + backupEnvironment(commandDirectory, { + BACKUP_TIMESTAMP: "20260720T030104Z", + }), + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /failed to enumerate completed backups/); + assert.doesNotMatch(result.stdout, /path=/); + for (const backup of existingBackups) { + assert.equal(readFileSync(join(backupDirectory, backup), "utf8"), backup); + } + assert.equal(readFileSync(join(backupDirectory, "unrelated.txt"), "utf8"), "retain me"); + } finally { + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(backupDirectory, { force: true, recursive: true }); + rmSync(commandDirectory, { force: true, recursive: true }); + } +}); + +test("refuses full disks and removes a failed-pipeline partial file", () => { + const environmentFile = createDatabaseEnvironment(); + const backupDirectory = canonicalTemporaryDirectory("jyotisha-backup-failure-"); + const fullDiskCommands = mkdtempSync(join(tmpdir(), "jyotisha-backup-full-disk-command-")); + const pipelineCommands = mkdtempSync(join(tmpdir(), "jyotisha-backup-pipeline-command-")); + + try { + safeDiskCommand(fullDiskCommands, 70); + const fullDisk = runBackup( + environmentFile.file, + backupDirectory, + backupEnvironment(fullDiskCommands, { BACKUP_TIMESTAMP: "20260720T040101Z" }), + ); + assert.notEqual(fullDisk.status, 0); + assert.match(fullDisk.stderr, /disk usage must be below 70 percent/); + assert.deepEqual(listBackups(backupDirectory), []); + + safeDiskCommand(pipelineCommands); + writeCommand(pipelineCommands, "docker", "exit 92"); + const failedPipeline = runBackup( + environmentFile.file, + backupDirectory, + backupEnvironment(pipelineCommands, { BACKUP_TIMESTAMP: "20260720T040102Z" }), + ); + assert.notEqual(failedPipeline.status, 0); + assert.deepEqual(listBackups(backupDirectory), []); + assert.deepEqual(readdirSync(backupDirectory).filter((name) => name.endsWith(".partial")), []); + assert.deepEqual(readdirSync(backupDirectory).filter((name) => name.endsWith(".lock")), []); + } finally { + rmSync(environmentFile.directory, { force: true, recursive: true }); + rmSync(backupDirectory, { force: true, recursive: true }); + rmSync(fullDiskCommands, { force: true, recursive: true }); + rmSync(pipelineCommands, { force: true, recursive: true }); + } +}); diff --git a/frontend/tests/database-env-validator.test.ts b/frontend/tests/database-env-validator.test.ts new file mode 100644 index 00000000..e71ee529 --- /dev/null +++ b/frontend/tests/database-env-validator.test.ts @@ -0,0 +1,223 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + mkdtempSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; +import { test } from "node:test"; + +const repositoryRoot = fileURLToPath(new URL("../..", import.meta.url)); +const validator = join(repositoryRoot, "deploy/validate-staging-database-env.sh"); +const validEnvironment = [ + "POSTGRES_DB=jyotisha", + "POSTGRES_USER=postgres", + "POSTGRES_PASSWORD=postgres-test-password", + "SCHEMA_OWNER_PASSWORD=schema-owner-test-password", + "IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password", + "APP_RUNTIME_PASSWORD=app-runtime-test-password", + "ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password", + "MIGRATION_RUNNER_PASSWORD=migration-runner-test-password", + "BACKUP_READER_PASSWORD=backup-reader-test-password", + "STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password", + "SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner%2Dtest-password@postgres:5432/jyotisha", +]; + +test("database env validator rejects Compose-compatible duplicate selectors without printing values", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + const composeFile = join(root, "compose.yml"); + + try { + writeFileSync( + composeFile, + [ + "services:", + " probe:", + " image: alpine", + " environment:", + " SELECTED: ${POSTGRES_DB}", + "", + ].join("\n"), + ); + writeFileSync( + envFile, + `${[...validEnvironment, " POSTGRES_DB = evil"].join("\n")}\n`, + { mode: 0o600 }, + ); + chmodSync(envFile, 0o600); + + const rendered = spawnSync( + "docker", + [ + "compose", + "--env-file", + envFile, + "-f", + composeFile, + "config", + "--format", + "json", + ], + { encoding: "utf8" }, + ); + assert.equal(rendered.status, 0, rendered.stderr); + assert.equal( + JSON.parse(rendered.stdout).services.probe.environment.SELECTED, + "evil", + ); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("database env validator rejects bare Compose-compatible duplicate definitions", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + + try { + writeFileSync( + envFile, + `${[...validEnvironment, " POSTGRES_DB"].join("\n")}\n`, + { mode: 0o600 }, + ); + chmodSync(envFile, 0o600); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.notEqual(result.status, 0); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("database env validator rejects quoted and interpolated required secrets", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + + try { + for (const password of ['""', "''", "${UNSET}"]) { + writeFileSync( + envFile, + `${validEnvironment + .map((line) => + line.startsWith("POSTGRES_PASSWORD=") + ? `POSTGRES_PASSWORD=${password}` + : line, + ) + .join("\n")}\n`, + { mode: 0o600 }, + ); + chmodSync(envFile, 0o600); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.notEqual(result.status, 0, password); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("database env validator accepts punctuated literal required secrets", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + + try { + for (const password of ["c2VjcmV0IT0=", '"secret!=value"']) { + writeFileSync( + envFile, + `${validEnvironment + .map((line) => + line.startsWith("POSTGRES_PASSWORD=") + ? `POSTGRES_PASSWORD=${password}` + : line, + ) + .join("\n")}\n`, + { mode: 0o600 }, + ); + chmodSync(envFile, 0o600); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "staging database environment validated\n"); + assert.doesNotMatch(`${result.stdout}${result.stderr}`, /secret!=value|c2VjcmV0IT0=/); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("database env validator rejects symlinks and unsafe modes", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + const target = join(root, "database-target.env"); + + try { + writeFileSync(target, `${validEnvironment.join("\n")}\n`, { mode: 0o600 }); + chmodSync(target, 0o600); + symlinkSync(target, envFile); + assert.notEqual( + spawnSync("bash", [validator, envFile], { encoding: "utf8" }).status, + 0, + ); + + rmSync(envFile); + writeFileSync(envFile, `${validEnvironment.join("\n")}\n`, { mode: 0o644 }); + chmodSync(envFile, 0o644); + assert.notEqual( + spawnSync("bash", [validator, envFile], { encoding: "utf8" }).status, + 0, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("database env validator accepts a private valid file without printing values", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-database-env-")); + const envFile = join(root, ".env.staging.database"); + + try { + writeFileSync(envFile, `${validEnvironment.join("\n")}\n`, { mode: 0o600 }); + chmodSync(envFile, 0o600); + + const result = spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "staging database environment validated\n"); + assert.doesNotMatch( + `${result.stdout}${result.stderr}`, + /postgres-test-password|schema-owner-test-password|staging-backup-test-password/, + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("staging database environment file is ignored", () => { + const result = spawnSync("git", ["check-ignore", ".env.staging.database"], { + cwd: repositoryRoot, + encoding: "utf8", + }); + + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, ".env.staging.database\n"); +}); diff --git a/frontend/tests/database-foundation.test.ts b/frontend/tests/database-foundation.test.ts new file mode 100644 index 00000000..d90a6d62 --- /dev/null +++ b/frontend/tests/database-foundation.test.ts @@ -0,0 +1,457 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { + appendFileSync, + copyFileSync, + mkdtempSync, + mkdirSync, + rmSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { test } from "node:test"; +import { readDatabaseUrl } from "../src/lib/db/config"; +import { startPostgresFixture } from "./helpers/postgres-fixture"; + +const migrationFilename = "20260720000100_backend_foundation.sql"; +const pendingFilename = "20260720000200_pending_check.sql"; +const concurrentFilename = "20260720000300_concurrent_lock.sql"; +const failingFilename = "20260720000400_atomic_rollback.sql"; +const malformedFilename = "20260720_malformed.sql"; +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); +const migrationPath = fileURLToPath( + new URL(`../db/migrations/${migrationFilename}`, import.meta.url), +); +const fixturePasswords = [ + "schema-owner-test-password", + "identity-runtime-test-password", + "app-runtime-test-password", + "admin-runtime-test-password", + "migration-runner-test-password", + "backup-reader-test-password", + "postgres-test-password", +]; + +type MigrationResult = { + status: number | null; + stdout: string; + stderr: string; +}; + +function migrationInvocation( + connectionString: string, + options: { check?: boolean; migrationsDirectory?: string }, +) { + return { + arguments: [runnerPath, ...(options.check ? ["--check"] : [])], + environment: { + SCHEMA_DATABASE_URL: connectionString, + ...(options.migrationsDirectory + ? { MIGRATIONS_DIRECTORY: options.migrationsDirectory } + : {}), + } as NodeJS.ProcessEnv, + }; +} + +function runMigration( + connectionString: string, + options: { check?: boolean; migrationsDirectory?: string } = {}, +): MigrationResult { + const invocation = migrationInvocation(connectionString, options); + const result = spawnSync( + process.execPath, + invocation.arguments, + { + encoding: "utf8", + env: invocation.environment, + }, + ); + return { status: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +function runMigrationAsync( + connectionString: string, + migrationsDirectory: string, +): Promise { + const invocation = migrationInvocation(connectionString, { + migrationsDirectory, + }); + + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, invocation.arguments, { + env: invocation.environment, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (status) => resolve({ status, stdout, stderr })); + }); +} + +function assertSafeOutput(result: MigrationResult): void { + const output = `${result.stdout}${result.stderr}`; + for (const password of fixturePasswords) { + assert.doesNotMatch(output, new RegExp(password)); + } +} + +test("readDatabaseUrl requires APP_DATABASE_URL", () => { + assert.throws( + () => readDatabaseUrl({} as NodeJS.ProcessEnv, "APP_DATABASE_URL"), + new Error("APP_DATABASE_URL is required"), + ); +}); + +test("migration runner is serialized, atomic, drift-safe, and read-only in check mode", async () => { + const fixture = startPostgresFixture(); + const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-migrations-")); + const copiedMigration = join(temporaryDirectory, migrationFilename); + const schemaUrl = fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ); + const results: MigrationResult[] = []; + + try { + copyFileSync(migrationPath, copiedMigration); + + const malformedPath = join(temporaryDirectory, malformedFilename); + writeFileSync(malformedPath, "select 1;\n"); + const malformedCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(malformedCheck); + assert.equal(malformedCheck.status, 1); + assert.match(malformedCheck.stderr, /invalid migration filename/); + unlinkSync(malformedPath); + + const missingLedgerCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(missingLedgerCheck); + assert.equal(missingLedgerCheck.status, 3); + assert.equal(missingLedgerCheck.stdout.trim(), migrationFilename); + assert.equal( + fixture.psql( + "select exists (select from pg_namespace where nspname = 'migration')", + ), + "f", + ); + + const firstRun = runMigration(schemaUrl, { + migrationsDirectory: temporaryDirectory, + }); + results.push(firstRun); + assert.equal(firstRun.status, 0, firstRun.stderr); + assert.match(firstRun.stdout, new RegExp(`applied ${migrationFilename}`)); + + const ledgerRow = fixture.psql(` + select filename || ':' || checksum || ':' || applied_at + from migration.schema_migrations + `); + const [filename, checksum] = ledgerRow.split(":"); + assert.equal(filename, migrationFilename); + assert.equal(checksum.length, 64); + + const secondRun = runMigration(schemaUrl, { + migrationsDirectory: temporaryDirectory, + }); + results.push(secondRun); + assert.equal(secondRun.status, 0, secondRun.stderr); + assert.match( + secondRun.stdout, + new RegExp(`already applied ${migrationFilename}`), + ); + assert.equal( + fixture.psql(` + select filename || ':' || checksum || ':' || applied_at + from migration.schema_migrations + `), + ledgerRow, + ); + + const currentCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(currentCheck); + assert.equal(currentCheck.status, 0, currentCheck.stderr); + + const emptyDirectory = join(temporaryDirectory, "empty"); + mkdirSync(emptyDirectory); + const missingFileCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: emptyDirectory, + }); + results.push(missingFileCheck); + assert.equal(missingFileCheck.status, 1); + assert.match( + missingFileCheck.stderr, + new RegExp(`migration file missing: ${migrationFilename}`), + ); + const missingFileApply = runMigration(schemaUrl, { + migrationsDirectory: emptyDirectory, + }); + results.push(missingFileApply); + assert.equal(missingFileApply.status, 1); + assert.match( + missingFileApply.stderr, + new RegExp(`migration file missing: ${migrationFilename}`), + ); + + const pendingPath = join(temporaryDirectory, pendingFilename); + mkdirSync(dirname(pendingPath), { recursive: true }); + writeFileSync(pendingPath, "create schema check_mode_side_effect;\n"); + const ledgerCountBeforeCheck = fixture.psql( + "select count(*) from migration.schema_migrations", + ); + const pendingCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(pendingCheck); + assert.equal(pendingCheck.status, 3, pendingCheck.stderr); + assert.equal(pendingCheck.stdout.trim(), pendingFilename); + assert.equal( + fixture.psql("select count(*) from migration.schema_migrations"), + ledgerCountBeforeCheck, + ); + assert.equal( + fixture.psql( + "select exists (select from pg_namespace where nspname = 'check_mode_side_effect')", + ), + "f", + ); + unlinkSync(pendingPath); + + appendFileSync(copiedMigration, " "); + const driftRun = runMigration(schemaUrl, { + migrationsDirectory: temporaryDirectory, + }); + results.push(driftRun); + assert.equal(driftRun.status, 1); + assert.match( + driftRun.stderr, + new RegExp(`migration checksum mismatch: ${migrationFilename}`), + ); + + const driftCheck = runMigration(schemaUrl, { + check: true, + migrationsDirectory: temporaryDirectory, + }); + results.push(driftCheck); + assert.equal(driftCheck.status, 1); + assert.match( + driftCheck.stderr, + new RegExp(`migration checksum mismatch: ${migrationFilename}`), + ); + copyFileSync(migrationPath, copiedMigration); + + writeFileSync( + join(temporaryDirectory, concurrentFilename), + "select pg_sleep(1);\ncreate schema concurrent_lock_probe;\n", + ); + const concurrentResults = await Promise.all([ + runMigrationAsync(schemaUrl, temporaryDirectory), + runMigrationAsync(schemaUrl, temporaryDirectory), + ]); + results.push(...concurrentResults); + assert.deepEqual( + concurrentResults.map((result) => result.status), + [0, 0], + ); + assert.deepEqual( + concurrentResults + .flatMap((result) => result.stdout.trim().split("\n")) + .filter((line) => line.endsWith(concurrentFilename)) + .sort(), + [ + `already applied ${concurrentFilename}`, + `applied ${concurrentFilename}`, + ].sort(), + ); + assert.equal( + fixture.psql(` + select count(*) + from migration.schema_migrations + where filename = '${concurrentFilename}' + `), + "1", + ); + + writeFileSync( + join(temporaryDirectory, failingFilename), + `create schema atomic_rollback_probe authorization schema_owner; +create table atomic_rollback_probe.parent (id integer primary key); +create table atomic_rollback_probe.child ( + parent_id integer references atomic_rollback_probe.parent(id) + deferrable initially deferred +); +insert into atomic_rollback_probe.child (parent_id) values (1); +`, + ); + const failingRun = runMigration(schemaUrl, { + migrationsDirectory: temporaryDirectory, + }); + results.push(failingRun); + assert.equal(failingRun.status, 1); + assert.match( + failingRun.stderr, + new RegExp(`migration failed: ${failingFilename}`), + ); + assert.equal( + fixture.psql(` + select exists ( + select from pg_namespace where nspname = 'atomic_rollback_probe' + ) + `), + "f", + ); + assert.equal( + fixture.psql(` + select count(*) + from migration.schema_migrations + where filename = '${failingFilename}' + `), + "0", + ); + + assert.equal( + fixture.psql( + "select has_database_privilege('app_runtime', current_database(), 'create')", + ), + "f", + ); + assert.equal( + fixture.psql( + "select has_table_privilege('app_runtime', 'migration.schema_migrations', 'select')", + ), + "f", + ); + + for (const result of results) assertSafeOutput(result); + } finally { + fixture.stop(); + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +}); + +test("foundation grants no direct runtime table DML and exposes only reviewed functions", () => { + const fixture = startPostgresFixture(); + const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-privileges-")); + const schemaUrl = fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ); + + try { + copyFileSync(migrationPath, join(temporaryDirectory, migrationFilename)); + const migration = runMigration(schemaUrl, { + migrationsDirectory: temporaryDirectory, + }); + assert.equal(migration.status, 0, migration.stderr); + + fixture.psqlAs( + "schema_owner", + "schema-owner-test-password", + ` + create table public.runtime_boundary_probe ( + id integer primary key, + value text not null + ); + create table identity.runtime_boundary_probe ( + id integer primary key, + value text not null + ); + create table audit.admin_event_probe ( + value text not null + ); + create function identity.unreviewed_identity_probe() + returns text + language sql + as 'select ''not callable''::text'; + create function audit.record_admin_event_probe(event_value text) + returns void + language sql + security definer + set search_path = pg_catalog, audit + as 'insert into audit.admin_event_probe(value) values (event_value)'; + grant execute on function audit.record_admin_event_probe(text) to admin_runtime; + `, + ); + + assert.throws(() => + fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + "insert into public.runtime_boundary_probe values (1, 'denied')", + ), + ); + assert.throws(() => + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "insert into audit.admin_event_probe values ('denied')", + ), + ); + assert.throws(() => + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + "insert into identity.runtime_boundary_probe values (1, 'denied')", + ), + ); + assert.throws(() => + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "update public.runtime_boundary_probe set value = 'denied'", + ), + ); + assert.equal( + fixture.psql( + "select has_function_privilege('identity_runtime', 'identity.unreviewed_identity_probe()', 'execute')", + ), + "f", + ); + assert.throws(() => + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + "select identity.unreviewed_identity_probe()", + ), + ); + + assert.equal( + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "select audit.record_admin_event_probe('approved')", + ), + "", + ); + assert.equal( + fixture.psql("select value from audit.admin_event_probe"), + "approved", + ); + } finally { + fixture.stop(); + rmSync(temporaryDirectory, { force: true, recursive: true }); + } +}); diff --git a/frontend/tests/database-topology.test.ts b/frontend/tests/database-topology.test.ts new file mode 100644 index 00000000..07f31500 --- /dev/null +++ b/frontend/tests/database-topology.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { startPostgresFixture } from "./helpers/postgres-fixture"; + +test("staging postgres is private and CI binds loopback only", () => { + const staging = readFileSync("../deploy/docker-compose.postgres.yml", "utf8"); + const ci = readFileSync("../deploy/docker-compose.postgres-ci.yml", "utf8"); + assert.match(staging, /image:\s*postgres:17-alpine/); + assert.doesNotMatch(staging, /^\s+ports:/m); + assert.match(ci, /127\.0\.0\.1:\$\{POSTGRES_HOST_PORT:-55432\}:5432/); +}); + +test("database roles have no cluster privileges", () => { + const fixture = startPostgresFixture(); + try { + assert.equal( + fixture.psql(` + select rolname || ':' || rolsuper || ':' || rolcreatedb || ':' || + rolcreaterole || ':' || rolbypassrls || ':' || + rolreplication || ':' || rolinherit + from pg_roles + where rolname in ('schema_owner','identity_runtime','app_runtime', + 'admin_runtime','migration_runner','backup_reader') + order by rolname + `), + [ + "admin_runtime:f:f:f:f:f:f", + "app_runtime:f:f:f:f:f:f", + "backup_reader:f:f:f:f:f:f", + "identity_runtime:f:f:f:f:f:f", + "migration_runner:f:f:f:f:f:f", + "schema_owner:f:f:f:f:f:f", + ].join("\n"), + ); + assert.equal( + fixture.psql( + "select nspowner::regrole::text from pg_namespace where nspname = 'public'", + ), + "schema_owner", + ); + assert.equal( + fixture.psql(` + select has_schema_privilege('schema_owner', 'public', 'create') + and has_schema_privilege('schema_owner', 'public', 'usage') + `), + "t", + ); + assert.equal( + fixture.psql(` + select coalesce(string_agg(privilege_type, ',' order by privilege_type), '') + from pg_namespace, + aclexplode(coalesce(nspacl, acldefault('n', nspowner))) + where nspname = 'public' + and grantee = 0 + and privilege_type in ('CREATE', 'USAGE') + `), + "", + ); + assert.equal( + fixture.psql(` + select pg_get_userbyid(datdba) + from pg_database + where datname = current_database() + `), + "postgres", + ); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 734f1d51..5eb6ac80 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -1,9 +1,22 @@ import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { + chmodSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; import test from "node:test"; +import { fileURLToPath } from "node:url"; test("health endpoint exposes deployment identity for production verification", () => { - const source = readFileSync(new URL("../src/app/api/health/route.ts", import.meta.url), "utf8"); + const source = readFileSync( + new URL("../src/app/api/health/route.ts", import.meta.url), + "utf8", + ); assert.match(source, /deployment:/); assert.match(source, /GITHUB_SHA/); @@ -11,11 +24,247 @@ test("health endpoint exposes deployment identity for production verification", assert.match(source, /gitCommit/); }); -test("production deployment passes the tested revision into the web runtime", () => { - const compose = readFileSync(new URL("../../deploy/docker-compose.server.yml", import.meta.url), "utf8"); - const workflow = readFileSync(new URL("../../.github/workflows/deploy-production.yml", import.meta.url), "utf8"); +test("manual production deployment passes the selected revision into the web runtime", () => { + const compose = readFileSync( + new URL("../../deploy/docker-compose.server.yml", import.meta.url), + "utf8", + ); + const workflow = readFileSync( + new URL("../../.github/workflows/deploy-production.yml", import.meta.url), + "utf8", + ); assert.match(compose, /GITHUB_SHA: \$\{GITHUB_SHA\}/); - assert.match(workflow, /DEPLOY_GIT_SHA: \$\{\{ github\.event\.workflow_run\.head_sha \|\| github\.sha \}\}/); + assert.match(workflow, /workflow_dispatch:/); + assert.doesNotMatch(workflow, /workflow_run:/); + assert.match(workflow, /DEPLOY_GIT_SHA: \$\{\{ github\.sha \}\}/); assert.match(workflow, /GITHUB_SHA='\$DEPLOY_GIT_SHA'/); + assert.match(workflow, /get\("deployment", \{\}\)\.get\("gitCommit"/); + assert.match(workflow, /DEPLOY_GIT_SHA/); + assert.match(workflow, /Production revision did not converge/); + assert.match(workflow, /git ls-remote origin refs\/heads\/main/); + assert.match(workflow, /steps\.revision\.outputs\.deploy == 'true'/); +}); + +test("server compose accepts staging paths while preserving production defaults", () => { + const compose = readFileSync( + new URL("../../deploy/docker-compose.server.yml", import.meta.url), + "utf8", + ); + + assert.match( + compose, + /env_file:\s*\n\s*- \$\{APP_ENV_FILE:-\.\.\/\.env\.production\}/, + ); + assert.match( + compose, + /\$\{CADDYFILE_PATH:-\.\/Caddyfile\}:\/etc\/caddy\/Caddyfile:ro/, + ); + assert.match( + compose, + /SITE_ADDRESS: \$\{SITE_ADDRESS:-https:\/\/jyotisha\.chat\}/, + ); +}); + +test("server compose defaults to local images without removing either build", () => { + const composeFile = fileURLToPath( + new URL("../../deploy/docker-compose.server.yml", import.meta.url), + ); + const compose = readFileSync(composeFile, "utf8"); + + assert.match(compose, /^\s+image: \$\{API_IMAGE:-jyotisha-api:local\}$/m); + assert.match(compose, /^\s+image: \$\{WEB_IMAGE:-jyotisha-web:local\}$/m); + + const root = mkdtempSync(join(tmpdir(), "jyotisha-server-compose-")); + const appEnvFile = join(root, ".env.production"); + writeFileSync(appEnvFile, "RUNTIME_FIXTURE=1\n"); + chmodSync(appEnvFile, 0o600); + + const env = { + ...process.env, + APP_ENV_FILE: appEnvFile, + CADDYFILE_PATH: fileURLToPath( + new URL("../../deploy/Caddyfile", import.meta.url), + ), + GITHUB_SHA: "0000000000000000000000000000000000000000", + NEXT_PUBLIC_SUPABASE_URL: "https://placeholder.supabase.co", + NEXT_PUBLIC_SUPABASE_ANON_KEY: "placeholder", + }; + delete env.API_IMAGE; + delete env.WEB_IMAGE; + + try { + const result = spawnSync( + "docker", + ["compose", "-f", composeFile, "config", "--format", "json"], + { encoding: "utf8", env }, + ); + assert.equal(result.status, 0, result.stderr); + const rendered = JSON.parse(result.stdout) as { + services: Record; + }; + assert.equal(rendered.services.api.image, "jyotisha-api:local"); + assert.equal(rendered.services.web.image, "jyotisha-web:local"); + assert.ok(rendered.services.api.build, "api build definition was removed"); + assert.ok(rendered.services.web.build, "web build definition was removed"); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("staging Caddy configuration serves only the configured staging address", () => { + const caddy = readFileSync( + new URL("../../deploy/Caddyfile.staging", import.meta.url), + "utf8", + ); + + assert.match(caddy, /\{\$SITE_ADDRESS:https:\/\/staging\.jyotisha\.chat\}/); + assert.match(caddy, /reverse_proxy web:3000/); + assert.doesNotMatch(caddy, /www\.jyotisha\.chat/); +}); + +test("staging deploy consumes only the isolated staging environment and tested revision", () => { + const qualityGate = readFileSync( + new URL("../../.github/workflows/backend-quality-gate.yml", import.meta.url), + "utf8", + ); + const workflow = readFileSync( + new URL("../../.github/workflows/deploy-staging.yml", import.meta.url), + "utf8", + ); + const syncController = readFileSync( + new URL("../../deploy/sync-staging-tree.sh", import.meta.url), + "utf8", + ); + + assert.match(qualityGate, /push:\s*\n\s*branches: \[staging\]/); + assert.match(workflow, /workflows: \["Staging Backend Quality Gate"\]/); + assert.match( + workflow, + /github\.event\.workflow_run\.head_branch == 'staging'/, + ); + assert.match(workflow, /actions: read/); + assert.match(workflow, /packages: read/); + assert.match(workflow, /environment:\s*\n\s*name: staging/); + assert.match(workflow, /deploy_sha:/); + assert.match(workflow, /\^\[0-9a-f\]\{40\}\$/); + assert.match( + workflow, + /actions\/workflows\/backend-quality-gate\.yml\/runs\?head_sha=/, + ); + assert.match(workflow, /STAGING_SSH_PRIVATE_KEY/); + assert.match(workflow, /vars\.STAGING_HOST/); + assert.match(workflow, /vars\.STAGING_KNOWN_HOSTS/); + assert.match(workflow, /test "\$DEPLOY_HOST" = "118\.26\.111\.127"/); + assert.match(workflow, /test "\$DEPLOY_USER" = "deploy"/); + assert.match(workflow, /test "\$DEPLOY_PATH" = "\/opt\/jyotisha-staging"/); + assert.match( + workflow, + /--include='\/deploy\/' --include='\/deploy\/\*\*\*' --exclude='\*'/, + ); + assert.match(workflow, /run-staging-deploy\.sh/); + assert.match(workflow, /steps\.images\.outputs\.api_image/); + assert.match(workflow, /steps\.images\.outputs\.web_image/); + assert.doesNotMatch(workflow, /PRODUCTION_SSH_PRIVATE_KEY/); + assert.doesNotMatch(workflow, /103\.117\.123\.53/); + assert.match(syncController, /--exclude='\/\.env\*'/); + assert.match(syncController, /--exclude='\/\.docker\/'/); + assert.match(syncController, /--exclude='\/backups\/'/); +}); + +test("staging env validator rejects selector drift, duplicates, and unsafe permissions", () => { + const validator = fileURLToPath( + new URL("../../deploy/validate-staging-env.sh", import.meta.url), + ); + const root = mkdtempSync(join(tmpdir(), "jyotisha-staging-env-")); + const envFile = join(root, ".env.staging"); + const composeFile = join(root, "compose.yml"); + const validSelectors = [ + "APP_ENV_FILE=../.env.staging", + "CADDYFILE_PATH=./Caddyfile.staging", + "SITE_ADDRESS=https://staging.jyotisha.chat", + ]; + const run = () => + spawnSync("bash", [validator, envFile], { encoding: "utf8" }); + const writeEnv = (lines: string[], mode = 0o600) => { + writeFileSync(envFile, `${lines.join("\n")}\n`); + chmodSync(envFile, mode); + }; + + try { + writeFileSync( + composeFile, + [ + "services:", + " probe:", + " image: alpine", + " environment:", + " SELECTED: ${APP_ENV_FILE}", + "", + ].join("\n"), + ); + writeEnv(validSelectors); + assert.equal(run().status, 0); + const shellOverride = spawnSync( + "docker", + [ + "compose", + "--env-file", + envFile, + "-f", + composeFile, + "config", + "--format", + "json", + ], + { + encoding: "utf8", + env: { ...process.env, APP_ENV_FILE: "../.env.production" }, + }, + ); + assert.equal(shellOverride.status, 0, shellOverride.stderr); + assert.equal( + JSON.parse(shellOverride.stdout).services.probe.environment.SELECTED, + "../.env.production", + ); + + writeEnv(["APP_ENV_FILE=../.env.production", ...validSelectors.slice(1)]); + assert.notEqual(run().status, 0); + + writeEnv([...validSelectors, "SITE_ADDRESS=https://example.invalid"]); + assert.notEqual(run().status, 0); + + writeEnv([...validSelectors, "APP_ENV_FILE = ../.env.production"]); + assert.notEqual(run().status, 0); + const rendered = spawnSync( + "docker", + [ + "compose", + "--env-file", + envFile, + "-f", + composeFile, + "config", + "--format", + "json", + ], + { encoding: "utf8" }, + ); + assert.equal(rendered.status, 0, rendered.stderr); + assert.equal( + JSON.parse(rendered.stdout).services.probe.environment.SELECTED, + "../.env.production", + ); + + writeEnv([...validSelectors, "export CADDYFILE_PATH=./Caddyfile"]); + assert.notEqual(run().status, 0); + + writeEnv([...validSelectors, "SITE_ADDRESS"]); + assert.notEqual(run().status, 0); + + writeEnv(validSelectors, 0o644); + assert.notEqual(run().status, 0); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); diff --git a/frontend/tests/helpers/postgres-fixture.ts b/frontend/tests/helpers/postgres-fixture.ts new file mode 100644 index 00000000..ebda7d75 --- /dev/null +++ b/frontend/tests/helpers/postgres-fixture.ts @@ -0,0 +1,185 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +export type PostgresFixture = { + projectName: string; + databaseEnvFile: string; + hostPort: number; + connectionUrl(role: string, password: string): string; + psql(sql: string): string; + psqlAs(role: string, password: string, sql: string): string; + stop(): void; +}; + +const databaseEnvironment = `POSTGRES_DB=jyotisha +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres-test-password +SCHEMA_OWNER_PASSWORD=schema-owner-test-password +IDENTITY_RUNTIME_PASSWORD=identity-runtime-test-password +APP_RUNTIME_PASSWORD=app-runtime-test-password +ADMIN_RUNTIME_PASSWORD=admin-runtime-test-password +MIGRATION_RUNNER_PASSWORD=migration-runner-test-password +BACKUP_READER_PASSWORD=backup-reader-test-password +STAGING_BACKUP_ENCRYPTION_KEY=staging-backup-test-password +SCHEMA_DATABASE_URL=postgresql://schema_owner:schema-owner-test-password@postgres:5432/jyotisha +`; + +function isPortAvailable(port: number): boolean { + return ( + spawnSync( + process.execPath, + [ + "-e", + `const server = require("node:net").createServer(); +server.once("error", () => process.exit(1)); +server.listen({ host: "127.0.0.1", port: Number(process.argv[1]) }, () => + server.close(() => process.exit(0)), +);`, + String(port), + ], + { stdio: "ignore" }, + ).status === 0 + ); +} + +type PortReservation = { port: number; directory: string }; + +function reserveAvailablePort(): PortReservation { + for (let port = 55432; port <= 55531; port += 1) { + const directory = join(tmpdir(), `jyotisha-postgres-port-${port}.lock`); + try { + mkdirSync(directory); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") continue; + throw error; + } + + if (isPortAvailable(port)) { + return { port, directory }; + } + rmSync(directory, { force: true, recursive: true }); + } + + throw new Error("no available PostgreSQL test port in 55432..55531"); +} + +function releasePort(reservation: PortReservation): void { + rmSync(reservation.directory, { force: true, recursive: true }); +} + +export function startPostgresFixture(): PostgresFixture { + const projectName = `jyotisha-postgres-${process.pid}-${Date.now()}`; + const temporaryDirectory = mkdtempSync(join(tmpdir(), "jyotisha-postgres-")); + const databaseEnvFile = join(temporaryDirectory, "database.env"); + let portReservation: PortReservation; + try { + portReservation = reserveAvailablePort(); + } catch (error) { + rmSync(temporaryDirectory, { force: true, recursive: true }); + throw error; + } + const hostPort = portReservation.port; + const composeArguments = [ + "compose", + "--project-name", + projectName, + "--env-file", + databaseEnvFile, + "-f", + "../deploy/docker-compose.postgres.yml", + "-f", + "../deploy/docker-compose.postgres-ci.yml", + ]; + const environment = { + ...process.env, + DATABASE_ENV_FILE: databaseEnvFile, + POSTGRES_HOST_PORT: String(hostPort), + }; + + try { + writeFileSync(databaseEnvFile, databaseEnvironment, { mode: 0o600 }); + chmodSync(databaseEnvFile, 0o600); + execFileSync( + "docker", + [...composeArguments, "up", "-d", "--wait", "postgres"], + { env: environment, stdio: "inherit" }, + ); + } catch (error) { + try { + try { + execFileSync( + "docker", + [...composeArguments, "down", "-v", "--remove-orphans"], + { env: environment, stdio: "inherit" }, + ); + } catch { + // Preserve the original startup failure. + } + } finally { + releasePort(portReservation); + rmSync(temporaryDirectory, { force: true, recursive: true }); + } + throw error; + } + + return { + projectName, + databaseEnvFile, + hostPort, + connectionUrl(role, password) { + return `postgresql://${role}:${password}@127.0.0.1:${hostPort}/jyotisha`; + }, + psql(sql) { + return execFileSync( + "docker", + [...composeArguments, "exec", "-T", "postgres", "psql", "-U", "postgres", "-d", "jyotisha", "-Atc", sql], + { encoding: "utf8", env: environment }, + ) + .trim() + .replace(/(^|:)false(?=:|$)/gm, "$1f"); + }, + psqlAs(role, password, sql) { + return execFileSync( + "docker", + [ + ...composeArguments, + "exec", + "-T", + "-e", + `PGPASSWORD=${password}`, + "postgres", + "psql", + "-v", + "ON_ERROR_STOP=1", + "-U", + role, + "-d", + "jyotisha", + "-Atc", + sql, + ], + { encoding: "utf8", env: environment }, + ).trim(); + }, + stop() { + try { + execFileSync( + "docker", + [...composeArguments, "down", "-v", "--remove-orphans"], + { env: environment, stdio: "inherit" }, + ); + } finally { + releasePort(portReservation); + rmSync(temporaryDirectory, { force: true, recursive: true }); + } + }, + }; +} diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts new file mode 100644 index 00000000..4e69f7e9 --- /dev/null +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -0,0 +1,377 @@ +import assert from "node:assert/strict"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { spawnSync } from "node:child_process"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const qualityWorkflow = new URL( + "../../.github/workflows/backend-quality-gate.yml", + import.meta.url, +); +const deployWorkflow = new URL( + "../../.github/workflows/deploy-staging.yml", + import.meta.url, +); +const migrationWorkflow = new URL( + "../../.github/workflows/migrate-staging-database.yml", + import.meta.url, +); +const deployScript = new URL( + "../../deploy/run-staging-deploy.sh", + import.meta.url, +); +const migrationScript = new URL( + "../../deploy/run-staging-migration.sh", + import.meta.url, +); +const syncScript = new URL( + "../../deploy/sync-staging-tree.sh", + import.meta.url, +); + +function read(url: URL): string { + return readFileSync(url, "utf8"); +} + +function assertOrder(text: string, labels: string[]): void { + let previous = -1; + for (const label of labels) { + const index = text.indexOf(label); + assert.ok(index > previous, `${label} is missing or out of order`); + previous = index; + } +} + +test("changed staging workflows are syntactically valid YAML", () => { + for (const workflow of [qualityWorkflow, deployWorkflow, migrationWorkflow]) { + const result = spawnSync( + "ruby", + ["-e", "require 'yaml'; YAML.parse_file(ARGV.fetch(0))", fileURLToPath(workflow)], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); + } +}); + +test("quality gate validates relevant changes once and publishes a digest manifest", () => { + const workflow = read(qualityWorkflow); + + assert.match(workflow, /pull_request:\n\s+paths:/); + for (const path of ["frontend/**", "deploy/**", "scripts/**", "tests/**"]) { + assert.match(workflow, new RegExp(`'${path.replaceAll("*", "\\*")}'`)); + } + assert.match(workflow, /push:\n\s+branches: \[staging\]/); + assert.match(workflow, /workflow_dispatch:/); + assert.equal((workflow.match(/npm test --prefix frontend/g) ?? []).length, 1); + assert.doesNotMatch(workflow, /npm run test:db --prefix frontend/); + assert.match(workflow, /id: api_build[\s\S]*steps\.api_build\.outputs\.digest/); + assert.match(workflow, /id: web_build[\s\S]*steps\.web_build\.outputs\.digest/); + assert.match(workflow, /\^sha256:\[0-9a-f\]\{64\}\$/); + assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/); + assert.match(workflow, /name: staging-image-manifest-\$\{\{ github\.sha \}\}/); + assert.match(workflow, /uses: actions\/upload-artifact@v4/); + assert.doesNotMatch(workflow, /(?:^|:)latest$/m); +}); + +test("quality gate builds the Python package with its declared backend dependencies", () => { + const workflow = read(qualityWorkflow); + + assert.match(workflow, /^\s+python -m build$/m); + assert.doesNotMatch(workflow, /python -m build --no-isolation/); +}); + +test("deployment test command includes manifest behavior coverage", () => { + const packageJson = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), + ) as { scripts: Record }; + const command = packageJson.scripts["test:deployment"]; + assert.match(command, /health-deployment\.test\.ts/); + assert.match(command, /staging-backend-workflows\.test\.ts/); + assert.match(command, /staging-image-manifest\.test\.ts/); +}); + +test("live staging sync preserves env, state, incoming files, and encrypted backups", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-live-sync-")); + const source = join(root, "source"); + const destination = join(root, "destination"); + mkdirSync(source); + mkdirSync(destination); + writeFileSync(join(source, "revision.txt"), "new\n"); + mkdirSync(join(source, ".docker")); + writeFileSync(join(source, ".docker", "config.json"), "temporary-token\n"); + writeFileSync(join(destination, "stale.txt"), "old\n"); + for (const relative of [ + ".env.staging", + ".env.staging.database", + ".state/deployed-revision", + ".incoming/other-run/payload", + "backups/staging-db/20260720.dump.gz.gpg", + ]) { + const target = join(destination, relative); + mkdirSync(join(target, ".."), { recursive: true }); + writeFileSync(target, "preserve\n"); + } + + try { + const result = spawnSync( + "bash", + [fileURLToPath(syncScript), source, destination], + { encoding: "utf8" }, + ); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(join(destination, "stale.txt")), false); + assert.equal(readFileSync(join(destination, "revision.txt"), "utf8"), "new\n"); + assert.equal(existsSync(join(destination, ".docker")), false); + for (const relative of [ + ".env.staging", + ".env.staging.database", + ".state/deployed-revision", + ".incoming/other-run/payload", + "backups/staging-db/20260720.dump.gz.gpg", + ]) { + assert.equal(existsSync(join(destination, relative)), true, relative); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("all staging mutations share Actions serialization and one host lock", () => { + const deployment = read(deployWorkflow); + const migration = read(migrationWorkflow); + const deployRunner = read(deployScript); + const migrationRunner = read(migrationScript); + + for (const workflow of [deployment, migration]) { + assert.match(workflow, /concurrency:\n\s+group: staging-mutation\n\s+cancel-in-progress: false/); + } + for (const runner of [deployRunner, migrationRunner]) { + assert.match(runner, /state_directory="\$DEPLOY_PATH\/\.state"/); + assert.match(runner, /state_directory\/mutation\.lock/); + assert.match(runner, /flock -n 9/); + assert.ok(runner.indexOf("flock -n 9") < runner.indexOf("sync-staging-tree.sh")); + assert.ok(runner.indexOf("flock -n 9") < runner.indexOf("docker")); + } +}); + +test("deploy and migration consume the exact successful gate artifact", () => { + const deployment = read(deployWorkflow); + const migration = read(migrationWorkflow); + + for (const workflow of [deployment, migration]) { + assert.match(workflow, /backend-quality-gate\.yml\/runs\?head_sha=/); + assert.match(workflow, /\.head_branch == "staging"/); + assert.match(workflow, /\.event == "push"/); + assert.match(workflow, /\.conclusion == "success"/); + assert.match(workflow, /sort_by\(\.id\) \| reverse \| first/); + assert.match(workflow, /uses: actions\/download-artifact@v4/); + assert.match(workflow, /run-id: \$\{\{ steps\.revision\.outputs\.gate_run_id \}\}/); + assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/); + assert.doesNotMatch(workflow, /jyotisha-(?:api|web):\$[A-Z_]*SHA/); + } +}); + +test("main owns the deployment control plane and target revisions are data only", () => { + for (const workflow of [read(deployWorkflow), read(migrationWorkflow)]) { + assert.match(workflow, /name: Checkout trusted main controller[\s\S]*ref: main/); + assert.match(workflow, /fetch-depth: 0/); + assert.match(workflow, /git merge-base --is-ancestor "\$DEPLOY_SHA" HEAD/); + assert.match(workflow, /--include='\/deploy\/' --include='\/deploy\/\*\*\*' --exclude='\*'/); + assert.doesNotMatch(workflow, /ref: \$\{\{ steps\.revision\.outputs\.sha \}\}/); + } +}); + +test("staging mutations retain every pending deployment and migration", () => { + for (const workflow of [read(deployWorkflow), read(migrationWorkflow)]) { + assert.match( + workflow, + /concurrency:\n group: staging-mutation\n cancel-in-progress: false\n queue: max/, + ); + } +}); + +test("automatic staging paths reject stale and divergent revisions", () => { + const deployment = read(deployWorkflow); + const migration = read(migrationWorkflow); + + assert.match(deployment, /allow_rollback:/); + assert.match(deployment, /rollback authorization is manual-only/); + assert.match(deployment, /stale staging revision refused/); + assert.match(deployment, /compare\/\$previous_sha\.\.\.\$DEPLOY_SHA/); + assert.match(deployment, /\.status == "ahead" and \.merge_base_commit\.sha == \$base/); + assert.match(migration, /stale staging migration refused/); + assert.match(migration, /staging advanced during migration; refusing stale deployment dispatch/); + assert.match(migration, /\{ref:"main",inputs:\{deploy_sha:\$deploy_sha,allow_rollback:"false"\}\}/); +}); + +test("remote deployment verifies running image IDs, RepoDigests, and application SHA", () => { + const runner = read(deployScript); + + assert.match(runner, /docker inspect --format '\{\{\.Image\}\}'/); + assert.match(runner, /docker image inspect --format '\{\{\.Id\}\}' "\$expected_ref"/); + assert.match(runner, /RepoDigests/); + assert.match(runner, /grep -Fqx "\$expected_ref"/); + assert.match(runner, /publicBody\.deployment\?\.gitCommit !== process\.env\.EXPECTED_SHA/); + assert.match(runner, /mv -f "\$revision_file" "\$state_directory\/deployed-revision"/); + assert.match(runner, /restoring prior application images/); + assert.match( + runner, + /switched=true\n"\$\{compose\[@\]\}" up -d --no-build --remove-orphans\n/, + ); + assert.doesNotMatch(runner, /jyotisha-(?:api|web):\$DEPLOY_SHA/); +}); + +test("first immutable deployment rolls back to validated local image IDs", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-local-image-rollback-")); + const deploymentPath = join(root, "live"); + const incomingPath = join(deploymentPath, ".incoming", "run-1"); + const incomingDeploy = join(incomingPath, "deploy"); + const liveDeploy = join(deploymentPath, "deploy"); + const mockBin = join(root, "bin"); + const rollbackLog = join(root, "rollback.log"); + const previousSha = "1".repeat(40); + const previousApiId = `sha256:${"a".repeat(64)}`; + const previousWebId = `sha256:${"b".repeat(64)}`; + const nextSha = "2".repeat(40); + + mkdirSync(incomingDeploy, { recursive: true }); + mkdirSync(liveDeploy, { recursive: true }); + mkdirSync(join(deploymentPath, ".state"), { recursive: true }); + mkdirSync(mockBin); + writeFileSync(join(deploymentPath, ".state", "deployed-revision"), `${previousSha}\n`); + for (const script of [ + join(incomingDeploy, "sync-staging-tree.sh"), + join(liveDeploy, "validate-staging-env.sh"), + join(liveDeploy, "validate-staging-database-env.sh"), + ]) { + writeFileSync(script, "#!/usr/bin/env bash\nexit 0\n"); + chmodSync(script, 0o755); + } + writeFileSync( + join(mockBin, "docker"), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "if [ \"$1\" = ps ]; then", + " case \"$*\" in", + " *service=api*) echo container-api ;;", + " *service=web*) echo container-web ;;", + " esac", + " exit 0", + "fi", + "if [ \"$1\" = inspect ]; then", + ` if [ \"\${!#}\" = container-api ]; then echo '${previousApiId}'; else echo '${previousWebId}'; fi`, + " exit 0", + "fi", + "if [ \"$1\" = image ]; then exit 0; fi", + "if [ \"$1\" = compose ]; then", + " if [[ \" $* \" == *\" up -d --no-build --remove-orphans \"* ]]; then", + ` printf '%s|%s|%s|%s\\n' \"\${API_IMAGE:-}\" \"\${WEB_IMAGE:-}\" \"\${GITHUB_SHA:-}\" \"$*\" >>'${rollbackLog}'`, + " [[ \"$*\" == *\" api web caddy\" ]] && exit 0", + " exit 42", + " fi", + " exit 0", + "fi", + "exit 1", + "", + ].join("\n"), + ); + chmodSync(join(mockBin, "docker"), 0o755); + writeFileSync(join(mockBin, "flock"), "#!/usr/bin/env bash\nexit 0\n"); + chmodSync(join(mockBin, "flock"), 0o755); + + try { + const result = spawnSync("bash", [fileURLToPath(deployScript)], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${mockBin}:${process.env.PATH ?? ""}`, + INCOMING_PATH: incomingPath, + DEPLOY_PATH: deploymentPath, + API_IMAGE: `ghcr.io/jesse-ux/jyotisha-api@sha256:${"c".repeat(64)}`, + WEB_IMAGE: `ghcr.io/jesse-ux/jyotisha-web@sha256:${"d".repeat(64)}`, + DEPLOY_SHA: nextSha, + EXPECTED_PREVIOUS_SHA: previousSha, + ALLOW_ROLLBACK: "false", + FORWARD_REVISION_VERIFIED: "true", + DOCKER_CONFIG: join(incomingPath, ".docker"), + STAGING_URL: "https://staging.jyotisha.chat", + }, + }); + assert.equal(result.status, 42, result.stderr); + const attempts = readFileSync(rollbackLog, "utf8").trim().split("\n"); + assert.equal(attempts.length, 2); + assert.match( + attempts[1], + new RegExp(`^${previousApiId}\\|${previousWebId}\\|${previousSha}\\|`), + ); + assert.match(attempts[1], /api web caddy$/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("normal deployment checks migrations but never applies them", () => { + const runner = read(deployScript); + assertOrder(runner, [ + "pull api web", + "up -d --no-build --pull never --wait postgres", + "--profile migration-check run --rm migration-checker", + "up -d --no-build --remove-orphans", + ]); + assert.match(runner, /pending migrations: run Migrate Staging Database/); + assert.match(runner, /exit 3/); + assert.doesNotMatch(runner, /--profile migration run --rm migrator/); + assert.doesNotMatch(runner, /npm\s+run\s+db:migrate(?!:check)/); + assert.doesNotMatch(runner, /pull api web postgres/); +}); + +test("manual migration uses only PostgreSQL and the digest-pinned migrator", () => { + const workflow = read(migrationWorkflow); + const runner = read(migrationScript); + + assert.match(workflow, /^on:\n\s+workflow_dispatch:/m); + assert.doesNotMatch(workflow, /workflow_run:|\n\s+push:/); + assert.match(runner, /docker pull "\$WEB_IMAGE"/); + assert.match(runner, /up -d --no-build --pull never --wait postgres/); + assert.match(runner, /-f deploy\/docker-compose\.postgres\.yml/); + assert.match(runner, /--profile migration run --rm migrator/); + assert.match(runner, /select filename from migration\.schema_migrations order by filename/); + assert.doesNotMatch(runner, /docker-compose\.server\.yml/); + assert.doesNotMatch(runner, /\bup\b[^\n]*(?:api|web|caddy)/); +}); + +test("run-local registry state and incoming trees are always cleaned up", () => { + for (const workflow of [read(deployWorkflow), read(migrationWorkflow)]) { + assert.match(workflow, /DOCKER_CONFIG='\$INCOMING_PATH\/\.docker'/); + assert.match(workflow, /if: always\(\) && steps\.incoming\.outputs\.path != ''/); + assert.match(workflow, /docker logout ghcr\.io/); + assert.match(workflow, /rm -rf -- '\$INCOMING_PATH'/); + assert.match( + workflow, + /install -d -m 700 [^\n]*\$incoming[^\n]*\n\s+echo "path=\$incoming" >>"\$GITHUB_OUTPUT"\n\s+rsync/, + ); + assert.doesNotMatch(workflow, /--password(?:\s|=)/); + } +}); + +test("production remains manual-only and separate from staging database automation", () => { + const production = readFileSync( + new URL("../../.github/workflows/deploy-production.yml", import.meta.url), + "utf8", + ); + assert.match(production, /^on:\n\s+workflow_dispatch:/m); + assert.doesNotMatch(production, /workflow_run:|\n\s+push:/); + assert.doesNotMatch(production, /docker-compose\.postgres\.yml|db:migrate/); +}); + +test("staging scripts pass shell syntax validation", () => { + for (const script of [deployScript, migrationScript, syncScript]) { + const path = fileURLToPath(script); + chmodSync(path, 0o755); + const result = spawnSync("bash", ["-n", path], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + } +}); diff --git a/frontend/tests/staging-image-manifest.test.ts b/frontend/tests/staging-image-manifest.test.ts new file mode 100644 index 00000000..2fc9e656 --- /dev/null +++ b/frontend/tests/staging-image-manifest.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseStagingImageManifest } from "../scripts/staging-image-manifest.mjs"; + +const gitSha = "0123456789abcdef0123456789abcdef01234567"; +const apiDigest = `sha256:${"a".repeat(64)}`; +const webDigest = `sha256:${"b".repeat(64)}`; + +function validManifest(): string { + return [ + `git_sha=${gitSha}`, + `api_digest=${apiDigest}`, + `web_digest=${webDigest}`, + "", + ].join("\n"); +} + +test("manifest produces immutable GHCR digest references", () => { + assert.deepEqual(parseStagingImageManifest(validManifest(), gitSha), { + gitSha, + apiDigest, + webDigest, + apiImage: `ghcr.io/jesse-ux/jyotisha-api@${apiDigest}`, + webImage: `ghcr.io/jesse-ux/jyotisha-web@${webDigest}`, + }); +}); + +test("manifest rejects revision drift, mutable tags, duplicates, extras, and malformed digests", () => { + const invalid = [ + validManifest().replace(gitSha, "f".repeat(40)), + validManifest().replace(apiDigest, `${gitSha}`), + validManifest().replace(apiDigest, `sha256:${"A".repeat(64)}`), + validManifest().replace( + `web_digest=${webDigest}`, + `api_digest=${apiDigest}`, + ), + `${validManifest()}extra=value\n`, + validManifest().replace("api_digest=", "api_image=ghcr.io/example:"), + ]; + + for (const contents of invalid) { + assert.throws(() => parseStagingImageManifest(contents, gitSha)); + } +}); diff --git a/references/oracle/compatibility_skill_readiness_dashboard_2026_07_20.json b/references/oracle/compatibility_skill_readiness_dashboard_2026_07_20.json new file mode 100644 index 00000000..d0e7ca2e --- /dev/null +++ b/references/oracle/compatibility_skill_readiness_dashboard_2026_07_20.json @@ -0,0 +1,159 @@ +{ + "scope": "compatibility_skill_readiness_dashboard", + "created_at": "2026-07-20", + "status": "dashboard_v1", + "claim_status": "partial", + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "summary": { + "layer_count": 9, + "runtime_available_count": 5, + "blocked_or_registry_only_count": 4, + "oracle_ready_count": 0 + }, + "skill_use_policy": { + "allowed": "Expose basic compatibility factors and relationship context with explicit low/partial evidence boundaries.", + "forbidden": "Do not present any layer as deterministic marriage success, divorce prediction, exact relationship timing, or complete synastry truth." + }, + "layers": [ + { + "layer_id": "ashtakoota_guna_milan", + "name": "Ashtakoota / Guna Milan", + "runtime_status": "available", + "runtime_path": "scripts/ashtakoot.py", + "runtime_path_exists": true, + "skill_status": "callable_basic", + "api_ui_status": "surface_audit_needed", + "external_oracle_status": "partial", + "commercial_sync_status": "basic_safe_with_boundary", + "evidence_paths": [ + "scripts/ashtakoot.py", + "references/oracle/ashtakoot_oracle_cases.json", + "scripts/synastry.py" + ], + "claim_boundary": "36-point compatibility can be exposed as one factor only; not deterministic marriage outcome." + }, + { + "layer_id": "mangal_dosha", + "name": "Mangal / Kuja Dosha matching", + "runtime_status": "available", + "runtime_path": "scripts/ashtakoot.py", + "runtime_path_exists": true, + "skill_status": "callable_basic", + "api_ui_status": "surface_audit_needed", + "external_oracle_status": "partial", + "commercial_sync_status": "basic_safe_with_boundary", + "evidence_paths": [ + "scripts/ashtakoot.py" + ], + "claim_boundary": "Use as risk flag and cancellation check; never as standalone rejection verdict." + }, + { + "layer_id": "d9_navamsa_relationship", + "name": "D9 Navamsa relationship layer", + "runtime_status": "available", + "runtime_path": "scripts/relationship_analysis.py", + "runtime_path_exists": true, + "skill_status": "callable_context", + "api_ui_status": "surface_audit_needed", + "external_oracle_status": "partial", + "commercial_sync_status": "safe_as_context", + "evidence_paths": [ + "scripts/relationship_analysis.py", + "references/navamsa-marriage-deep-analysis.md" + ], + "claim_boundary": "D9 can support relationship analysis; timing/outcome claims still require Dasha, transits, and external calibration." + }, + { + "layer_id": "darakaraka", + "name": "Darakaraka spouse significator", + "runtime_status": "available", + "runtime_path": "scripts/darakaraka_reader.py", + "runtime_path_exists": true, + "skill_status": "callable_context", + "api_ui_status": "surface_audit_needed", + "external_oracle_status": "source_reference_only", + "commercial_sync_status": "safe_as_context", + "evidence_paths": [ + "scripts/darakaraka_reader.py", + "references/darakaraka-complete-guide.md" + ], + "claim_boundary": "May describe spouse/relationship themes; not a compatibility score or event proof." + }, + { + "layer_id": "upapada_lagna", + "name": "Upapada Lagna marriage image", + "runtime_status": "available_as_chart_field", + "runtime_path": "scripts/jaimini.py", + "runtime_path_exists": true, + "skill_status": "callable_context", + "api_ui_status": "surface_audit_needed", + "external_oracle_status": "source_reference_only", + "commercial_sync_status": "safe_as_context", + "evidence_paths": [ + "scripts/jaimini.py", + "references/data-bridge-mapping.md", + "references/jaimini-complete-system.md" + ], + "claim_boundary": "UL is a relationship image layer; must not replace full chart, D9, Dasha, or event evidence." + }, + { + "layer_id": "relationship_combinations", + "name": "Relationship rule-family combinations", + "runtime_status": "registry_only", + "runtime_path": null, + "runtime_path_exists": false, + "skill_status": "contract_only", + "api_ui_status": "no_runtime_surface", + "external_oracle_status": "missing", + "commercial_sync_status": "research_only", + "evidence_paths": [ + "references/oracle/relationship_combinations_rule_family_registry_2026_07_19.json" + ], + "claim_boundary": "Indexed rule families still need source packets, deduplication, tests, and claim gates before runtime use." + }, + { + "layer_id": "relationship_ashtakavarga_overlay", + "name": "Relationship Ashtakavarga overlay", + "runtime_status": "missing_runtime", + "runtime_path": null, + "runtime_path_exists": false, + "skill_status": "not_invoked", + "api_ui_status": "no_runtime_surface", + "external_oracle_status": "missing", + "commercial_sync_status": "blocked_until_oracle", + "evidence_paths": [ + "references/oracle/ashtakavarga_advanced_usage_gap_registry_2026_07_19.json" + ], + "claim_boundary": "Do not expose relationship AV overlay until rules, examples, and field-level oracle packets exist." + }, + { + "layer_id": "planet_lagna_kuta", + "name": "Planet/Lagna Kuta variants", + "runtime_status": "registry_only", + "runtime_path": null, + "runtime_path_exists": false, + "skill_status": "not_invoked", + "api_ui_status": "no_runtime_surface", + "external_oracle_status": "missing", + "commercial_sync_status": "blocked_until_oracle", + "evidence_paths": [ + "references/oracle/compatibility_full_system_gap_registry_2026_07_19.json" + ], + "claim_boundary": "Do not claim full top-tier compatibility until Planet/Lagna Kuta variants and worked examples are validated." + }, + { + "layer_id": "western_composite_davidson_boundary", + "name": "Western composite / Davidson boundary", + "runtime_status": "out_of_scope", + "runtime_path": null, + "runtime_path_exists": false, + "skill_status": "not_invoked", + "api_ui_status": "no_vedic_surface", + "external_oracle_status": "not_applicable_vedic_core", + "commercial_sync_status": "out_of_scope_for_vedic_core", + "evidence_paths": [], + "claim_boundary": "Keep out of Vedic commercial runtime unless explicitly scoped as cross-system research." + } + ] +} diff --git a/references/oracle/event_judgment_fragment_rule_family_registry_2026_07_21.json b/references/oracle/event_judgment_fragment_rule_family_registry_2026_07_21.json new file mode 100644 index 00000000..5a3bfbd8 --- /dev/null +++ b/references/oracle/event_judgment_fragment_rule_family_registry_2026_07_21.json @@ -0,0 +1,53 @@ +{ + "scope": "event_judgment_fragment_rule_family_registry", + "created_at": "2026-07-21", + "claim_status": "ready_contract", + "source_fragment": "/Users/wuyongnaren/.workbuddy/backups/jyotish-vedic-astrology-20260711-154109/scripts/event_judgment_engine.py", + "source_policy": "rule_family_inventory_only_no_runtime_copy", + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "boundary": "Migrates WorkBuddy event_judgment_engine as a rule-family registry only. No old engine implementation is copied; current event_judgment_skeleton remains the runtime authority.", + "route_families": [ + { + "route": "relationship", + "required_families": [ + {"key": "d9_navamsa", "phase": "promise", "module": "varga_full", "claim": "Marriage/relationship questions must include D9."}, + {"key": "upapada_lagna", "phase": "promise", "module": "special_lagnas", "claim": "UL must be present or explicitly blocked."}, + {"key": "darakaraka", "phase": "manifestation", "module": "jaimini", "claim": "DK layer supports manifestation reading."}, + {"key": "marriage_convergence", "phase": "activation", "module": "dasa_convergence", "claim": "Activation must not rely on static promise alone."}, + {"key": "vimshottari_current", "phase": "timing", "module": "dasha", "claim": "Timing requires Vimshottari."}, + {"key": "narayana_current", "phase": "timing", "module": "narayana_dasha", "claim": "Timing requires Narayana cross-check; conflict downgrades certainty."} + ] + }, + { + "route": "career", + "required_families": [ + {"key": "d10_dasamsa", "phase": "promise", "module": "varga_full", "claim": "Career questions must include D10."}, + {"key": "a10_karma_pada", "phase": "promise", "module": "special_lagnas", "claim": "A10/Karma Pada must be present or explicitly blocked."}, + {"key": "amatyakaraka", "phase": "manifestation", "module": "jaimini", "claim": "AmK supports vocation manifestation layer."}, + {"key": "shadbala", "phase": "manifestation", "module": "shadbala", "claim": "Strength layer must be included with current partial/oracle boundary."}, + {"key": "career_convergence", "phase": "activation", "module": "dasa_convergence", "claim": "Activation must include career dasha convergence."}, + {"key": "vimshottari_current", "phase": "timing", "module": "dasha", "claim": "Timing requires Vimshottari."}, + {"key": "narayana_current", "phase": "timing", "module": "narayana_dasha", "claim": "Timing requires Narayana cross-check."} + ] + }, + { + "route": "wealth", + "required_families": [ + {"key": "d2_hora", "phase": "promise", "module": "varga_full", "claim": "Wealth questions must include D2."}, + {"key": "d10_dasamsa", "phase": "promise", "module": "varga_full", "claim": "Career/status channel must be checked for wealth manifestation."}, + {"key": "ashtakavarga_house_scores", "phase": "manifestation", "module": "ashtakavarga", "claim": "House-score support must be included with current AV boundary."}, + {"key": "wealth_convergence", "phase": "activation", "module": "dasa_convergence", "claim": "Activation must include wealth/family convergence."}, + {"key": "gains_convergence", "phase": "activation", "module": "dasa_convergence", "claim": "Gains/wishes convergence is a separate activation layer."}, + {"key": "vimshottari_current", "phase": "timing", "module": "dasha", "claim": "Timing requires Vimshottari."}, + {"key": "narayana_current", "phase": "timing", "module": "narayana_dasha", "claim": "Timing requires Narayana cross-check."} + ] + } + ], + "global_requirements": [ + "Every route must map evidence into promise, activation, manifestation and timing phases.", + "Missing promise/activation/manifestation/timing evidence blocks or downgrades the verdict.", + "Timing/event claims must use Vimshottari + Narayana, not Vimshottari alone.", + "Functional Benefic/Malefic remains mandatory from AGENTS.md even though the WorkBuddy fragment did not contain it." + ] +} diff --git a/references/oracle/evidence_packet_index_2026_07_19.json b/references/oracle/evidence_packet_index_2026_07_19.json index df1b31dd..5b990aab 100644 --- a/references/oracle/evidence_packet_index_2026_07_19.json +++ b/references/oracle/evidence_packet_index_2026_07_19.json @@ -1,582 +1,430 @@ { - "boundary": "Index of current governance packets only. Raw oracle artifacts remain in references/oracle/artifacts and are not all duplicated here.", + "scope": "evidence_packet_index", "created_at": "2026-07-19", + "status": "active_index_v1", + "production_tuning_allowed": false, + "boundary": "Index of current governance packets only. Raw oracle artifacts remain in references/oracle/artifacts and are not all duplicated here.", + "summary": { + "packet_count": 103, + "blocked_or_partial_count": 51, + "human_review_required_count": 3 + }, "packets": [ { - "claim_boundary": "Source/formula/unit registry exists; absolute numeric parity still requires worked examples and oracle comparison.", + "packet_id": "formula_source_kb", + "path": "references/oracle/formula_source_knowledge_base_2026_07_19.json", + "domain": "formula_sources", "claim_status": "partial", "consumer_policy": "research_to_commercial_contract_only", - "domain": "formula_sources", - "packet_id": "formula_source_kb", - "path": "references/oracle/formula_source_knowledge_base_2026_07_19.json" + "claim_boundary": "Source/formula/unit registry exists; absolute numeric parity still requires worked examples and oracle comparison." }, { - "claim_boundary": "Profile schema contract is ready; UI/API adoption must prove gender optional fields do not affect core chart math.", - "claim_status": "ready_contract", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "profile_schema", "packet_id": "profile_schema_contract", - "path": "references/oracle/profile_schema_contract_2026_07_19.json" - }, - { - "claim_boundary": "Gender affects only optional relationship/birth-context interpretation, not chart calculation.", + "path": "references/oracle/profile_schema_contract_2026_07_19.json", + "domain": "profile_schema", "claim_status": "ready_contract", "consumer_policy": "research_to_commercial_contract_only", - "domain": "relationship_interpretation", + "claim_boundary": "Profile schema contract is ready; UI/API adoption must prove gender optional fields do not affect core chart math." + }, + { "packet_id": "gender_interpretation_contract", - "path": "references/oracle/gender_interpretation_contract_2026_07_19.json" - }, - { - "claim_boundary": "Technique registry only; no predictive truth upgrade.", + "path": "references/oracle/gender_interpretation_contract_2026_07_19.json", + "domain": "relationship_interpretation", "claim_status": "ready_contract", "consumer_policy": "research_to_commercial_contract_only", - "domain": "relationship_interpretation", - "packet_id": "relationship_gender_role_registry", - "path": "references/relationship_gender_role_technique_registry_2026_07_19.json" + "claim_boundary": "Gender affects only optional relationship/birth-context interpretation, not chart calculation." }, { - "claim_boundary": "Final labels are blank; blind evaluation must not run until independent human review and freeze.", + "packet_id": "relationship_gender_role_registry", + "path": "references/relationship_gender_role_technique_registry_2026_07_19.json", + "domain": "relationship_interpretation", + "claim_status": "ready_contract", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Technique registry only; no predictive truth upgrade." + }, + { + "packet_id": "day_level_human_annotation_packet", + "path": "references/real_case_calibration/day_level_holdout_v3_human_annotation_packet_2026_07_19.json", + "domain": "timing_holdout", "claim_status": "blocked", "consumer_policy": "human_review_required", - "domain": "timing_holdout", - "packet_id": "day_level_human_annotation_packet", - "path": "references/real_case_calibration/day_level_holdout_v3_human_annotation_packet_2026_07_19.json" + "claim_boundary": "Final labels are blank; blind evaluation must not run until independent human review and freeze." }, { - "claim_boundary": "Registry exists; public worked examples and field-level numeric oracle comparison still missing.", - "claim_status": "blocked", - "consumer_policy": "research_only", - "domain": "horary_annual_sensitive_points", "packet_id": "prashna_tajika_oracle_packet", - "path": "references/oracle/prashna_tajika_saham_gulika_sphuta_oracle_packet_2026_07_19.json" - }, - { - "claim_boundary": "Local NuGet candidate is pinned; hosted API identity/source commit remains blocked.", + "path": "references/oracle/prashna_tajika_saham_gulika_sphuta_oracle_packet_2026_07_19.json", + "domain": "horary_annual_sensitive_points", "claim_status": "blocked", "consumer_policy": "research_only", - "domain": "external_oracle_identity", - "packet_id": "vedastro_identity_evidence_audit", - "path": "references/oracle/vedastro_identity_evidence_audit_2026_07_19.json" + "claim_boundary": "Registry exists; public worked examples and field-level numeric oracle comparison still missing." }, { - "claim_boundary": "Deltas grouped by component; formulas/units/school variants remain open.", + "packet_id": "vedastro_identity_evidence_audit", + "path": "references/oracle/vedastro_identity_evidence_audit_2026_07_19.json", + "domain": "external_oracle_identity", + "claim_status": "blocked", + "consumer_policy": "research_only", + "claim_boundary": "Local NuGet candidate is pinned; hosted API identity/source commit remains blocked." + }, + { + "packet_id": "xalen_shadbala_av_delta_report", + "path": "references/oracle/xalen_shadbala_av_component_delta_report_2026_07_19.json", + "domain": "shadbala_ashtakavarga", "claim_status": "partial", "consumer_policy": "research_only", - "domain": "shadbala_ashtakavarga", - "packet_id": "xalen_shadbala_av_delta_report", - "path": "references/oracle/xalen_shadbala_av_component_delta_report_2026_07_19.json" + "claim_boundary": "Deltas grouped by component; formulas/units/school variants remain open." }, { - "claim_boundary": "60 tickets remain open; no majority-vote truth.", + "packet_id": "three_engine_mismatch_closure_queue", + "path": "references/oracle/three_engine_mismatch_closure_queue_2026_07_19.json", + "domain": "three_engine_parity", "claim_status": "open_queue", "consumer_policy": "research_only", - "domain": "three_engine_parity", - "packet_id": "three_engine_mismatch_closure_queue", - "path": "references/oracle/three_engine_mismatch_closure_queue_2026_07_19.json" + "claim_boundary": "60 tickets remain open; no majority-vote truth." }, { - "claim_boundary": "Inventory prevents missed fragments; files are not capability closure by themselves.", - "claim_status": "partial", - "consumer_policy": "research_only", - "domain": "technique_fragments", "packet_id": "technique_fragment_inventory", - "path": "references/oracle/technique_fragment_inventory_2026_07_19.json" - }, - { - "claim_boundary": "Runtime coverage exists; entry/display/source/oracle contracts remain.", + "path": "references/oracle/technique_fragment_inventory_2026_07_19.json", + "domain": "technique_fragments", "claim_status": "partial", "consumer_policy": "research_only", - "domain": "varga_jaimini", + "claim_boundary": "Inventory prevents missed fragments; files are not capability closure by themselves." + }, + { "packet_id": "varga_karaka_promotion_audit", - "path": "references/oracle/technique_promotion_audit_varga_karaka_2026_07_19.json" - }, - { - "claim_boundary": "Runtime usage exists; formula source/oracle/display contracts remain.", + "path": "references/oracle/technique_promotion_audit_varga_karaka_2026_07_19.json", + "domain": "varga_jaimini", "claim_status": "partial", "consumer_policy": "research_only", - "domain": "strength_state_layers", + "claim_boundary": "Runtime coverage exists; entry/display/source/oracle contracts remain." + }, + { "packet_id": "vimsopaka_avastha_promotion_audit", - "path": "references/oracle/technique_promotion_audit_vimsopaka_avastha_2026_07_19.json" - }, - { - "claim_boundary": "Panchanga runtime exists; KP/Muhurta/Gochara remain reference-only or need license/source/oracle closure.", + "path": "references/oracle/technique_promotion_audit_vimsopaka_avastha_2026_07_19.json", + "domain": "strength_state_layers", "claim_status": "partial", "consumer_policy": "research_only", - "domain": "kp_gochara_muhurta_panchanga", + "claim_boundary": "Runtime usage exists; formula source/oracle/display contracts remain." + }, + { "packet_id": "kp_gochara_muhurta_promotion_audit", - "path": "references/oracle/technique_promotion_audit_kp_gochara_muhurta_2026_07_19.json" + "path": "references/oracle/technique_promotion_audit_kp_gochara_muhurta_2026_07_19.json", + "domain": "kp_gochara_muhurta_panchanga", + "claim_status": "partial", + "consumer_policy": "research_only", + "claim_boundary": "Panchanga runtime exists; KP/Muhurta/Gochara remain reference-only or need license/source/oracle closure." }, { - "claim_boundary": "Runtime gate can block or degrade high claims from blocked/partial/open evidence packets; production tuning remains false.", - "claim_status": "ready_contract", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "claim_governance", "packet_id": "claim_audit_runtime_gate", - "path": "scripts/claim_audit_runtime_gate.py" - }, - { - "claim_boundary": "Screenshot labels identify missing conception-chart and relationship-combination families, but unreadable images and source packets remain unresolved.", - "claim_status": "partial", - "consumer_policy": "research_only", - "domain": "source_ingestion", - "packet_id": "screenshot_technique_gap_audit", - "path": "references/oracle/screenshot_technique_gap_audit_2026_07_19.json" - }, - { - "claim_boundary": "Research-only Adhana/Niseka registry; no medical/fertility/user-facing claim until source, worked example, privacy, and safety gates close.", - "claim_status": "blocked", - "consumer_policy": "research_only", - "domain": "conception_chart", - "packet_id": "conception_chart_adhana_niseka_registry", - "path": "references/oracle/conception_chart_adhana_niseka_registry_2026_07_19.json" - }, - { - "claim_boundary": "Relationship/children/hostility combination families are indexed but require source packets, deduplication, tests, and claim gates before runtime use.", - "claim_status": "partial", - "consumer_policy": "research_only", - "domain": "relationship_rule_families", - "packet_id": "relationship_combinations_rule_family_registry", - "path": "references/oracle/relationship_combinations_rule_family_registry_2026_07_19.json" - }, - { - "claim_boundary": "Corrects optimistic technique_registry statuses before any skill completeness claim.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "skill_capability_truth", - "packet_id": "skill_truth_overlay", - "path": "references/oracle/skill_truth_overlay_2026_07_19.json" - }, - { - "claim_boundary": "Rectification now exposes candidate sweep, Vimshottari scoring, Varga sensitivity, Narayana/Jaimini/Vimsopaka cross gates, and Shadbala-AV/Gochara observation gates; all remain candidate-only and cannot confirm birth-time truth.", - "claim_status": "partial", - "consumer_policy": "research_only", - "domain": "birth_time_rectification", - "packet_id": "rectification_technique_usage_audit", - "path": "references/oracle/rectification_technique_usage_audit_2026_07_19.json" - }, - { - "claim_boundary": "Effective skill view applies the truth overlay before capability claims; raw technique_registry status is not sufficient.", + "path": "scripts/claim_audit_runtime_gate.py", + "domain": "claim_governance", "claim_status": "ready_contract", "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Runtime gate can block or degrade high claims from blocked/partial/open evidence packets; production tuning remains false." + }, + { + "packet_id": "screenshot_technique_gap_audit", + "path": "references/oracle/screenshot_technique_gap_audit_2026_07_19.json", + "domain": "source_ingestion", + "claim_status": "partial", + "consumer_policy": "research_only", + "claim_boundary": "Screenshot labels identify missing conception-chart and relationship-combination families, but unreadable images and source packets remain unresolved." + }, + { + "packet_id": "conception_chart_adhana_niseka_registry", + "path": "references/oracle/conception_chart_adhana_niseka_registry_2026_07_19.json", + "domain": "conception_chart", + "claim_status": "blocked", + "consumer_policy": "research_only", + "claim_boundary": "Research-only Adhana/Niseka registry; no medical/fertility/user-facing claim until source, worked example, privacy, and safety gates close." + }, + { + "packet_id": "relationship_combinations_rule_family_registry", + "path": "references/oracle/relationship_combinations_rule_family_registry_2026_07_19.json", + "domain": "relationship_rule_families", + "claim_status": "partial", + "consumer_policy": "research_only", + "claim_boundary": "Relationship/children/hostility combination families are indexed but require source packets, deduplication, tests, and claim gates before runtime use." + }, + { + "packet_id": "skill_truth_overlay", + "path": "references/oracle/skill_truth_overlay_2026_07_19.json", "domain": "skill_capability_truth", - "packet_id": "effective_skill_capability_view", - "path": "references/oracle/effective_skill_capability_view_2026_07_19.json" + "claim_status": "partial", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Corrects optimistic technique_registry statuses before any skill completeness claim." }, { - "claim_boundary": "All planned rectification layers have partial guarded runtime surfaces; Shadbala-AV remains formula/unit partial and Gochara remains negative-holdout blocked, so outputs stay exploratory.", - "claim_status": "partial", - "consumer_policy": "research_only", + "packet_id": "rectification_technique_usage_audit", + "path": "references/oracle/rectification_technique_usage_audit_2026_07_19.json", "domain": "birth_time_rectification", + "claim_status": "partial", + "consumer_policy": "research_only", + "claim_boundary": "Rectification now exposes candidate sweep, Vimshottari scoring, Varga sensitivity, Narayana/Jaimini/Vimsopaka cross gates, and Shadbala-AV/Gochara observation gates; all remain candidate-only and cannot confirm birth-time truth." + }, + { + "packet_id": "effective_skill_capability_view", + "path": "references/oracle/effective_skill_capability_view_2026_07_19.json", + "domain": "skill_capability_truth", + "claim_status": "ready_contract", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Effective skill view applies the truth overlay before capability claims; raw technique_registry status is not sufficient." + }, + { "packet_id": "rectification_missing_layer_integration_plan", - "path": "references/oracle/rectification_missing_layer_integration_plan_2026_07_19.json" + "path": "references/oracle/rectification_missing_layer_integration_plan_2026_07_19.json", + "domain": "birth_time_rectification", + "claim_status": "partial", + "consumer_policy": "research_only", + "claim_boundary": "All planned rectification layers have partial guarded runtime surfaces; Shadbala-AV remains formula/unit partial and Gochara remains negative-holdout blocked, so outputs stay exploratory." }, { - "claim_boundary": "KP precision timing gap registry exists; star/sub-lord, ruling planets and significator tables remain blocked until source/oracle/holdout gates close.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing", "packet_id": "kp_precision_timing_gap_registry", - "path": "references/oracle/kp_precision_timing_gap_registry_2026_07_19.json" - }, - { - "claim_boundary": "Panchanga base exists, but full Muhurta scoring remains blocked until Tarabala/Chandrabala/Panchaka/Rahu Kalam examples and source contracts close.", + "path": "references/oracle/kp_precision_timing_gap_registry_2026_07_19.json", + "domain": "kp_precision_timing", "claim_status": "partial", "consumer_policy": "research_to_commercial_contract_only", - "domain": "muhurta", + "claim_boundary": "KP precision timing gap registry exists; star/sub-lord, ruling planets and significator tables remain blocked until source/oracle/holdout gates close." + }, + { "packet_id": "muhurta_full_system_gap_registry", - "path": "references/oracle/muhurta_full_system_gap_registry_2026_07_19.json" - }, - { - "claim_boundary": "Core BAV/SAV exists; Kakshya transit, sensitive transit signs/nakshatras and annual SAV timing remain observation-only until oracle/holdout gates close.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "ashtakavarga_advanced_usage", - "packet_id": "ashtakavarga_advanced_usage_gap_registry", - "path": "references/oracle/ashtakavarga_advanced_usage_gap_registry_2026_07_19.json" - }, - { - "claim_boundary": "Ashtakoota core exists; extended planet/Lagna Kuta, relationship AV overlay and rule-family combinations remain partial/research-only.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "compatibility", - "packet_id": "compatibility_full_system_gap_registry", - "path": "references/oracle/compatibility_full_system_gap_registry_2026_07_19.json" - }, - { - "claim_boundary": "KP star/sub-lord and significator probe exposes raw fields and hash, but cannot drive precise event timing without exact KP cusp oracle and independent negative holdout validation.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing", - "packet_id": "kp_precision_timing_probe", - "path": "scripts/kp_precision_timing_probe.py" - }, - { - "claim_boundary": "Records local/OSS reusable candidates for KP, Muhurta, AV and compatibility; discovery only, no runtime truth upgrade.", - "claim_status": "partial", - "consumer_policy": "research_only", - "domain": "reuse_governance", - "packet_id": "local_oss_reuse_sweep_kp_muhurta_av_compat", - "path": "references/oracle/local_oss_reuse_sweep_kp_muhurta_av_compat_2026_07_19.json" - }, - { - "claim_boundary": "Exact KP cusp contract is defined, but numeric cusp oracle and negative timing holdout are missing; current runtime remains supporting probe only.", - "claim_status": "blocked", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing", - "packet_id": "kp_cusp_precision_contract", - "path": "references/oracle/kp_cusp_precision_contract_2026_07_19.json" - }, - { - "claim_boundary": "Aggregates external numeric oracle, independent negative holdout, exact KP cusp and full scoring gates; blocks production tuning, verified timing and birth-time truth while any gate remains partial/blocked.", - "claim_status": "blocked", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "closure_governance", - "packet_id": "high_rigor_closure_gate", - "path": "scripts/high_rigor_closure_gate.py" - }, - { - "claim_boundary": "KP external sub-lord table hash runner fixes fixture hash when present and reports fixture_missing when absent; no timing truth upgrade.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing", - "packet_id": "kp_external_table_hash_manifest", - "path": "scripts/kp_external_table_hash_manifest.py" - }, - { - "claim_boundary": "Exact KP cusp worked-example queue defines required oracle fields; remains awaiting public numeric examples.", - "claim_status": "blocked", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing", - "packet_id": "kp_cusp_worked_example_oracle_queue", - "path": "references/oracle/kp_cusp_worked_example_oracle_queue_2026_07_19.json" - }, - { - "claim_boundary": "Muhurta factor probe exposes Tarabala/Chandrabala/Rahu Kalam/Abhijit observations only; no verified Muhurta verdict.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", + "path": "references/oracle/muhurta_full_system_gap_registry_2026_07_19.json", "domain": "muhurta", - "packet_id": "muhurta_factor_probe", - "path": "scripts/muhurta_factor_probe.py" + "claim_status": "partial", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Panchanga base exists, but full Muhurta scoring remains blocked until Tarabala/Chandrabala/Panchaka/Rahu Kalam examples and source contracts close." }, { - "claim_boundary": "Human annotation intake appends frozen independent labels; production tuning remains blocked until frozen gate passes.", + "packet_id": "ashtakavarga_advanced_usage_gap_registry", + "path": "references/oracle/ashtakavarga_advanced_usage_gap_registry_2026_07_19.json", + "domain": "ashtakavarga_advanced_usage", + "claim_status": "partial", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Core BAV/SAV exists; Kakshya transit, sensitive transit signs/nakshatras and annual SAV timing remain observation-only until oracle/holdout gates close." + }, + { + "packet_id": "compatibility_full_system_gap_registry", + "path": "references/oracle/compatibility_full_system_gap_registry_2026_07_19.json", + "domain": "compatibility", + "claim_status": "partial", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Ashtakoota core exists; extended planet/Lagna Kuta, relationship AV overlay and rule-family combinations remain partial/research-only." + }, + { + "packet_id": "kp_precision_timing_probe", + "path": "scripts/kp_precision_timing_probe.py", + "domain": "kp_precision_timing", + "claim_status": "partial", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "KP star/sub-lord and significator probe exposes raw fields and hash, but cannot drive precise event timing without exact KP cusp oracle and independent negative holdout validation." + }, + { + "packet_id": "local_oss_reuse_sweep_kp_muhurta_av_compat", + "path": "references/oracle/local_oss_reuse_sweep_kp_muhurta_av_compat_2026_07_19.json", + "domain": "reuse_governance", "claim_status": "partial", "consumer_policy": "research_only", - "domain": "timing_holdout", - "packet_id": "day_level_holdout_intake", - "path": "scripts/day_level_holdout_intake.py" + "claim_boundary": "Records local/OSS reusable candidates for KP, Muhurta, AV and compatibility; discovery only, no runtime truth upgrade." }, { - "claim_boundary": "Permissive OSS candidates and worked-example queues are registered for probes only; no truth-matrix or production-tuning upgrade.", - "claim_status": "observation_only", - "consumer_policy": "research_observation_only", - "domain": "oss_reuse_and_worked_examples", - "packet_id": "permissive_oss_worked_example_registry", - "path": "references/oracle/permissive_oss_worked_example_registry_2026_07_19.json" - }, - { - "claim_boundary": "Local OSS fragments are inventoried with hash/license/API hints only; no runtime dependency or truth upgrade.", - "claim_status": "observation_only", - "consumer_policy": "research_observation_only", - "domain": "oss_reuse_and_worked_examples", - "packet_id": "local_oss_observation_inventory", - "path": "scripts/local_oss_observation_inventory.py" - }, - { - "claim_boundary": "panchanga_api local snapshot is docs-only; no numeric parity claim.", - "claim_status": "observation_only", - "consumer_policy": "research_observation_only", - "domain": "muhurta_panchanga", - "packet_id": "panchanga_api_observation_manifest", - "path": "references/oracle/panchanga_api_observation_manifest_2026_07_19.json" - }, - { - "claim_boundary": "Field-level closure queue only; does not resolve mismatch rows.", - "claim_status": "observation_only", - "consumer_policy": "research_observation_only", - "domain": "three_engine_field_closure", - "packet_id": "jyotishganit_field_closure_queue", - "path": "references/oracle/jyotishganit_field_closure_queue_2026_07_19.json" - }, - { - "claim_boundary": "Locates KP source surface and missing/candidate table paths; no cusp truth upgrade.", - "claim_status": "observation_only", - "consumer_policy": "research_observation_only", + "packet_id": "kp_cusp_precision_contract", + "path": "references/oracle/kp_cusp_precision_contract_2026_07_19.json", "domain": "kp_precision_timing", - "packet_id": "vedicastro_kp_surface_locator", - "path": "scripts/vedicastro_kp_surface_locator.py" + "claim_status": "blocked", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Exact KP cusp contract is defined, but numeric cusp oracle and negative timing holdout are missing; current runtime remains supporting probe only." }, { - "claim_boundary": "rishi-ai-mcp and vedic-astro-skills stay out of numeric truth layer.", + "packet_id": "high_rigor_closure_gate", + "path": "scripts/high_rigor_closure_gate.py", + "domain": "closure_governance", + "claim_status": "blocked", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Aggregates external numeric oracle, independent negative holdout, exact KP cusp and full scoring gates; blocks production tuning, verified timing and birth-time truth while any gate remains partial/blocked." + }, + { + "packet_id": "kp_external_table_hash_manifest", + "path": "scripts/kp_external_table_hash_manifest.py", + "domain": "kp_precision_timing", + "claim_status": "partial", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "KP external sub-lord table hash runner fixes fixture hash when present and reports fixture_missing when absent; no timing truth upgrade." + }, + { + "packet_id": "kp_cusp_worked_example_oracle_queue", + "path": "references/oracle/kp_cusp_worked_example_oracle_queue_2026_07_19.json", + "domain": "kp_precision_timing", + "claim_status": "blocked", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Exact KP cusp worked-example queue defines required oracle fields; remains awaiting public numeric examples." + }, + { + "packet_id": "muhurta_factor_probe", + "path": "scripts/muhurta_factor_probe.py", + "domain": "muhurta", + "claim_status": "partial", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Muhurta factor probe exposes Tarabala/Chandrabala/Rahu Kalam/Abhijit observations only; no verified Muhurta verdict." + }, + { + "packet_id": "day_level_holdout_intake", + "path": "scripts/day_level_holdout_intake.py", + "domain": "timing_holdout", + "claim_status": "partial", + "consumer_policy": "research_only", + "claim_boundary": "Human annotation intake appends frozen independent labels; production tuning remains blocked until frozen gate passes." + }, + { + "packet_id": "permissive_oss_worked_example_registry", + "path": "references/oracle/permissive_oss_worked_example_registry_2026_07_19.json", + "domain": "oss_reuse_and_worked_examples", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Permissive OSS candidates and worked-example queues are registered for probes only; no truth-matrix or production-tuning upgrade." + }, + { + "packet_id": "local_oss_observation_inventory", + "path": "scripts/local_oss_observation_inventory.py", + "domain": "oss_reuse_and_worked_examples", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Local OSS fragments are inventoried with hash/license/API hints only; no runtime dependency or truth upgrade." + }, + { + "packet_id": "panchanga_api_observation_manifest", + "path": "references/oracle/panchanga_api_observation_manifest_2026_07_19.json", + "domain": "muhurta_panchanga", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "panchanga_api local snapshot is docs-only; no numeric parity claim." + }, + { + "packet_id": "jyotishganit_field_closure_queue", + "path": "references/oracle/jyotishganit_field_closure_queue_2026_07_19.json", + "domain": "three_engine_field_closure", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Field-level closure queue only; does not resolve mismatch rows." + }, + { + "packet_id": "vedicastro_kp_surface_locator", + "path": "scripts/vedicastro_kp_surface_locator.py", + "domain": "kp_precision_timing", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Locates KP source surface and missing/candidate table paths; no cusp truth upgrade." + }, + { + "packet_id": "product_skill_source_boundary", + "path": "references/oracle/product_skill_source_boundary_2026_07_19.json", + "domain": "skill_product_reference", "claim_status": "reference_only", "consumer_policy": "skill_prompt_reference_only", - "domain": "skill_product_reference", - "packet_id": "product_skill_source_boundary", - "path": "references/oracle/product_skill_source_boundary_2026_07_19.json" + "claim_boundary": "rishi-ai-mcp and vedic-astro-skills stay out of numeric truth layer." }, { - "claim_boundary": "jyotishganit D2/D4/D9/D10 Panchanga AV raw/hash attached; Shadbala missing remains partial.", - "claim_status": "observation_only", - "consumer_policy": "research_observation_only", - "domain": "three_engine_field_closure", "packet_id": "jyotishganit_field_probe", - "path": "references/oracle/jyotishganit_field_probe_steve_jobs_2026_07_19.json" - }, - { - "claim_boundary": "VedicAstro KP API/source hash captured; exact cusp truth still requires public numeric worked examples.", + "path": "references/oracle/jyotishganit_field_probe_steve_jobs_2026_07_19.json", + "domain": "three_engine_field_closure", "claim_status": "observation_only", "consumer_policy": "research_observation_only", - "domain": "kp_precision_timing", - "packet_id": "vedicastro_kp_api_probe", - "path": "references/oracle/vedicastro_kp_api_probe_2026_07_19.json" + "claim_boundary": "jyotishganit D2/D4/D9/D10 Panchanga AV raw/hash attached; Shadbala missing remains partial." }, { - "claim_boundary": "Public worked-example queue only; URLs/search terms are not oracle-ready until numeric fields and hashes are captured.", + "packet_id": "vedicastro_kp_api_probe", + "path": "references/oracle/vedicastro_kp_api_probe_2026_07_19.json", + "domain": "kp_precision_timing", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "VedicAstro KP API/source hash captured; exact cusp truth still requires public numeric worked examples." + }, + { + "packet_id": "public_worked_example_queue", + "path": "references/oracle/public_worked_example_queue_2026_07_19.json", + "domain": "worked_example_collection", "claim_status": "open_queue", "consumer_policy": "research_queue_only", - "domain": "worked_example_collection", - "packet_id": "public_worked_example_queue", - "path": "references/oracle/public_worked_example_queue_2026_07_19.json" + "claim_boundary": "Public worked-example queue only; URLs/search terms are not oracle-ready until numeric fields and hashes are captured." }, { - "claim_boundary": "D1-D60 rows are enumerated; generic fallback rows are not verified formal varga support.", + "packet_id": "d1_d60_varga_mapping_registry", + "path": "references/oracle/d1_d60_varga_mapping_registry_2026_07_19.json", + "domain": "varga_mapping", "claim_status": "partial", "consumer_policy": "research_to_commercial_contract_only", - "domain": "varga_mapping", - "packet_id": "d1_d60_varga_mapping_registry", - "path": "references/oracle/d1_d60_varga_mapping_registry_2026_07_19.json" + "claim_boundary": "D1-D60 rows are enumerated; generic fallback rows are not verified formal varga support." }, { - "claim_boundary": "Local vs jyotishganit D2/D4/D9/D10 sign comparison with hash; mismatch attribution still pending.", + "packet_id": "jyotishganit_vs_local_field_comparison", + "path": "references/oracle/jyotishganit_vs_local_field_comparison_steve_jobs_2026_07_19.json", + "domain": "three_engine_field_closure", "claim_status": "observation_only", "consumer_policy": "research_observation_only", - "domain": "three_engine_field_closure", - "packet_id": "jyotishganit_vs_local_field_comparison", - "path": "references/oracle/jyotishganit_vs_local_field_comparison_steve_jobs_2026_07_19.json" + "claim_boundary": "Local vs jyotishganit D2/D4/D9/D10 sign comparison with hash; mismatch attribution still pending." }, { - "claim_boundary": "40 generic-only Dn rows require source/formula/oracle closure or remain hidden generic fallback.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "varga_mapping", "packet_id": "d1_d60_generic_gap_queue", - "path": "references/oracle/d1_d60_generic_gap_queue_2026_07_19.json" - }, - { - "claim_boundary": "Open-source Jyotish projects may provide source candidates, probes, raw artifacts, and worked-example leads; they are not final truth until license, commit/hash, input contract, and field-level oracle gates close.", - "claim_status": "source_intake_only", - "consumer_policy": "pin_hash_license_gate_before_runtime_use", - "domain": "oss_source_reuse_governance", - "packet_id": "authoritative_oss_jyotish_source_intake", - "path": "references/oracle/authoritative_oss_jyotish_source_intake_2026_07_19.json" - }, - { - "claim_boundary": "D10 Rahu/Ketu mismatch is attributed to node longitude and boundary crossing; do not tune formulas or assert engine truth until node mode/settings are pinned.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "varga_node_mode", - "packet_id": "d10_rahu_ketu_node_mode_attribution", - "path": "references/oracle/d10_rahu_ketu_node_mode_attribution_2026_07_19.json" - }, - { - "claim_boundary": "Candidate public pages are triaged only; no row is oracle-ready until exact input, settings, expected numeric values, raw/hash and replay are archived.", - "claim_status": "open_queue", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "worked_example_collection", - "packet_id": "public_worked_example_candidate_numeric_audit", - "path": "references/oracle/public_worked_example_candidate_numeric_audit_2026_07_19.json" - }, - { - "claim_boundary": "Sidereal flatlib dependency is reproducibly installable in a temporary isolated path and the VedicAstro KP API surface is callable; this remains observation_only and must not enter truth matrix until numeric worked-example replay closes.", - "claim_status": "observation_only", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing_dependency", - "packet_id": "vedicastro_flatlib_sidereal_install_probe", - "path": "references/oracle/vedicastro_flatlib_sidereal_install_probe_2026_07_19.json" - }, - { - "claim_boundary": "Temporary flatlib/polars VedicAstro KP probe captures dependency/import state only; no runtime truth upgrade.", - "claim_status": "observation_only", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing_dependency", - "packet_id": "vedicastro_kp_api_probe_flatlib_polars_tmp", - "path": "references/oracle/vedicastro_kp_api_probe_flatlib_polars_tmp_2026_07_19.json" - }, - { - "claim_boundary": "VedicAstro KP lord/sub/sub-sub runtime surface is callable in isolated sidereal flatlib environment; no precision-timing truth upgrade until public numeric KP worked-example replay closes.", - "claim_status": "observation_only", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing_dependency", - "packet_id": "vedicastro_kp_runtime_surface_probe", - "path": "references/oracle/vedicastro_kp_runtime_surface_probe_2026_07_19.json" - }, - { - "claim_boundary": "Queue tracks KP cusp, KP sub-lord table, Tarabala/Chandrabala, Rahu Kalam, and Shadbala numeric packets. Runtime KP raw exists, but no row is numeric_oracle_ready yet.", - "claim_status": "open_queue", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_muhurta_shadbala_numeric_oracle_queue", - "packet_id": "kp_muhurta_shadbala_numeric_packet_queue", - "path": "references/oracle/kp_muhurta_shadbala_numeric_packet_queue_2026_07_19.json" - }, - { - "claim_boundary": "VedicAstro KP house cusp star/sub/sub-sub raw for Steve Jobs fixture; observation-only until public worked-example replay closes.", - "claim_status": "observation_only", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing_dependency", - "packet_id": "vedicastro_kp_house_cusp_probe_steve_jobs", - "path": "references/oracle/vedicastro_kp_house_cusp_probe_steve_jobs_2026_07_19.json" - }, - { - "claim_boundary": "Probe script can collect VedicAstro KP cusp raw in isolated sidereal flatlib environment; not a production prediction engine.", - "claim_status": "tooling_observation_only", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing_dependency", - "packet_id": "vedicastro_kp_house_cusp_probe", - "path": "scripts/vedicastro_kp_house_cusp_probe.py" - }, - { - "claim_boundary": "Unified local/Xalen/jyotishganit/VedicAstro queue; same input/fields/hash/mismatch attribution only, no majority vote truth.", - "claim_status": "open_queue", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "four_engine_parity", - "packet_id": "four_engine_comparison_queue", - "path": "references/oracle/four_engine_comparison_queue_2026_07_19.json" - }, - { - "claim_boundary": "VedicAstro KP cusp batch raw exists for runtime observation; public worked-example oracle closure remains open.", - "claim_status": "observation_only", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing_dependency", - "packet_id": "vedicastro_kp_cusp_batch_probe", - "path": "references/oracle/vedicastro_kp_cusp_batch_probe_2026_07_19.json" - }, - { - "claim_boundary": "jyotishganit Shadbala raw surface is captured for Steve Jobs fixture; component closure remains open until field mapping and worked-example arbitration.", - "claim_status": "observation_only", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "shadbala_component_closure", - "packet_id": "jyotishganit_shadbala_surface_probe", - "path": "references/oracle/jyotishganit_shadbala_surface_probe_steve_jobs_2026_07_19.json" - }, - { - "claim_boundary": "Batch probe script collects KP cusp raw/hash in isolated runtime; not a production truth source.", - "claim_status": "tooling_observation_only", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "kp_precision_timing_dependency", - "packet_id": "vedicastro_kp_cusp_batch_probe_script", - "path": "scripts/vedicastro_kp_cusp_batch_probe.py" - }, - { - "claim_boundary": "D1-D60 public source candidates fill names/use cases only; generic-only Dn remains hidden where authority is missing.", - "claim_status": "open_queue", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "divisional_chart_source_queue", - "packet_id": "d1_d60_public_source_candidate_queue", - "path": "references/oracle/d1_d60_public_source_candidate_queue_2026_07_19.json" - }, - { - "claim_boundary": "Muhurta OSS factor queue records candidates; no production scoring upgrade without source, raw/hash, and worked examples.", - "claim_status": "open_queue", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "muhurta_factor_scoring", - "packet_id": "muhurta_oss_factor_scoring_queue", - "path": "references/oracle/muhurta_oss_factor_scoring_queue_2026_07_19.json" - }, - { - "claim_boundary": "Maps Shadbala components across local/Xalen/jyotishganit/VedicAstro observations. Closed components can inform provenance; open/method-variant rows must not be treated as absolute error.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "shadbala_component_closure", - "packet_id": "shadbala_component_closure_matrix", - "path": "references/oracle/shadbala_component_closure_matrix_2026_07_19.json" - }, - { - "claim_boundary": "Normalizes local/Xalen/jyotishganit/VP Jain Shadbala rows into Virupa/Rupa where available; classifications are arbitration queues, not absolute truth.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "shadbala_component_closure", - "packet_id": "shadbala_same_unit_normalizer", - "path": "references/oracle/shadbala_same_unit_normalizer_2026_07_19.json" - }, - { - "claim_boundary": "Current whole-machine fragment and OSS reuse guardrail is recorded; no numeric truth or production capability is upgraded by this packet.", - "claim_status": "ready_contract", - "consumer_policy": "pre_work_required_before_sync", - "domain": "fragment_oss_reuse_governance", - "packet_id": "deep_fragment_oss_reuse_gate_round2", - "path": "references/oracle/deep_fragment_oss_reuse_gate_round2_2026_07_19.json" - }, - { - "claim_boundary": "Classifies all 42 same-unit Shadbala rows into closed observation, method variant, or open mismatch; does not assert absolute formula truth.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "shadbala_component_closure", - "packet_id": "shadbala_component_closure_ledger", - "path": "references/oracle/shadbala_component_closure_ledger_2026_07_19.json" - }, - { - "claim_boundary": "Classifies Muhurta factors as supporting-context only; no scored verdict readiness until formulas, weights, public examples, and replay hashes close.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "muhurta_factor_scoring", - "packet_id": "muhurta_factor_scoring_readiness", - "path": "references/oracle/muhurta_factor_scoring_readiness_2026_07_19.json" - }, - { - "claim_boundary": "Classifies D1-D60 sources for names/use notes only; generic-only Dn remains hidden until formula/source/oracle gates close.", - "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", + "path": "references/oracle/d1_d60_generic_gap_queue_2026_07_19.json", "domain": "varga_mapping", - "packet_id": "d1_d60_source_use_readiness", - "path": "references/oracle/d1_d60_source_use_readiness_2026_07_19.json" - }, - { - "claim_boundary": "Reports frozen-label gap for day-level timing holdout; current pilot candidates are not independent labels and cannot unlock day/month claims.", - "claim_status": "blocked", - "consumer_policy": "human_review_required", - "domain": "timing_holdout", - "packet_id": "day_level_holdout_readiness_ledger", - "path": "references/real_case_calibration/day_level_holdout_readiness_ledger_2026_07_19.json" - }, - { - "claim_boundary": "Tracks explanation progress for mismatch rows; does not close original queue or allow majority-vote truth.", "claim_status": "partial", "consumer_policy": "research_to_commercial_contract_only", - "domain": "three_engine_parity", - "packet_id": "three_engine_mismatch_progress_ledger", - "path": "references/oracle/three_engine_mismatch_progress_ledger_2026_07_19.json" - }, - { - "claim_boundary": "Validates indexed packet paths/statuses/required fields; does not upgrade oracle claims.", - "claim_status": "ready_contract", - "consumer_policy": "research_to_commercial_contract_only", - "domain": "evidence_index_governance", - "packet_id": "evidence_packet_index_integrity", - "path": "scripts/evidence_packet_index_integrity.py" + "claim_boundary": "40 generic-only Dn rows require source/formula/oracle closure or remain hidden generic fallback." }, { "packet_id": "jyotishganit_mismatch_attribution_queue", "path": "references/oracle/jyotishganit_mismatch_attribution_queue_2026_07_19.json", - "domain": "three_engine_parity", + "domain": "three_engine_field_closure", "claim_status": "partial", - "consumer_policy": "research_to_commercial_contract_only", - "claim_boundary": "Queues formula/ayanamsa/node/rounding attribution for jyotishganit mismatches; no majority-vote truth." + "consumer_policy": "research_observation_only", + "claim_boundary": "After D4 alias normalization, only D10 Rahu/Ketu mismatch remains queued as node/shadow handling." }, { - "packet_id": "external_source_use_tier_registry_2026_07_20", - "path": "references/oracle/external_source_use_tier_registry_2026_07_20.json", - "domain": "external_source_governance", - "claim_status": "ready_contract", - "consumer_policy": "research_to_commercial_contract_only", - "claim_boundary": "Classifies OSS/web/real-case sources by allowed use tier; does not upgrade any source to oracle truth." + "packet_id": "vedicastro_kp_api_probe_flatlib_tmp", + "path": "references/oracle/vedicastro_kp_api_probe_flatlib_tmp_2026_07_19.json", + "domain": "kp_precision_timing", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Temporary flatlib import probe only; dependency version conflicts prevent runtime truth promotion." }, { - "packet_id": "high_rigor_closure_gate_snapshot_2026_07_20", - "path": "references/oracle/high_rigor_closure_gate_snapshot_2026_07_20.json", - "domain": "high_rigor_governance", - "claim_status": "blocked", - "consumer_policy": "research_to_commercial_contract_only", - "claim_boundary": "Aggregates current high-rigor gates; blocked/partial gates remain blocked until their evidence packets close." + "packet_id": "authoritative_oss_jyotish_source_intake", + "path": "references/oracle/authoritative_oss_jyotish_source_intake_2026_07_19.json", + "domain": "oss_source_reuse_governance", + "claim_status": "source_intake_only", + "consumer_policy": "pin_hash_license_gate_before_runtime_use", + "claim_boundary": "Open-source Jyotish projects may provide source candidates, probes, raw artifacts, and worked-example leads; they are not final truth until license, commit/hash, input contract, and field-level oracle gates close." + }, + { + "packet_id": "d10_rahu_ketu_node_mode_attribution", + "path": "references/oracle/d10_rahu_ketu_node_mode_attribution_2026_07_19.json", + "domain": "three_engine_field_closure", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "D10 Rahu/Ketu remaining mismatch attributed to node/shadow handling pending raw node settings." + }, + { + "packet_id": "vedicastro_kp_api_probe_flatlib_polars_tmp", + "path": "references/oracle/vedicastro_kp_api_probe_flatlib_polars_tmp_2026_07_19.json", + "domain": "kp_precision_timing", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Temporary flatlib/polars probe reaches flatlib.const AY_* API mismatch; no runtime truth upgrade." + }, + { + "packet_id": "public_worked_example_candidate_numeric_audit", + "path": "references/oracle/public_worked_example_candidate_numeric_audit_2026_07_19.json", + "domain": "worked_example_collection", + "claim_status": "open_queue", + "consumer_policy": "research_queue_only", + "claim_boundary": "Candidate URLs audited; no numeric oracle-ready row yet." + }, + { + "packet_id": "vedicastro_flatlib_sidereal_install_probe", + "path": "references/oracle/vedicastro_flatlib_sidereal_install_probe_2026_07_19.json", + "domain": "kp_precision_timing", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "VedicAstro official fork diliprk/flatlib@sidereal is pinned and KP API surface is callable in isolated temp env; no truth upgrade until numeric KP worked-example replay." }, { "packet_id": "jyotishganit_node_source_attribution", @@ -586,6 +434,94 @@ "consumer_policy": "research_observation_only", "claim_boundary": "Remaining D10 Rahu/Ketu mismatch attributed to node longitude source plus ayanamsa boundary crossing, not D10 formula." }, + { + "packet_id": "vedicastro_kp_runtime_surface_probe", + "path": "references/oracle/vedicastro_kp_runtime_surface_probe_2026_07_19.json", + "domain": "kp_precision_timing_dependency", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "VedicAstro KP lord/sub/sub-sub runtime surface is callable in isolated sidereal flatlib environment; no precision-timing truth upgrade until public numeric KP worked-example replay closes." + }, + { + "packet_id": "vedicastro_kp_house_cusp_probe", + "path": "references/oracle/vedicastro_kp_house_cusp_probe_steve_jobs_2026_07_19.json", + "domain": "kp_precision_timing", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "VedicAstro runtime house cusp star/sub/sub-sub raw/hash captured; public numeric KP oracle still missing." + }, + { + "packet_id": "kp_muhurta_shadbala_numeric_packet_queue", + "path": "references/oracle/kp_muhurta_shadbala_numeric_packet_queue_2026_07_19.json", + "domain": "worked_example_collection", + "claim_status": "open_queue", + "consumer_policy": "research_queue_only", + "claim_boundary": "KP runtime raw is attached; KP/Muhurta/Shadbala rows remain blocked until public numeric fields and raw hashes are captured." + }, + { + "packet_id": "jyotishganit_shadbala_surface_probe", + "path": "references/oracle/jyotishganit_shadbala_surface_probe_steve_jobs_2026_07_19.json", + "domain": "shadbala", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "jyotishganit Shadbala object surface raw/hash captured; component unit parity still pending." + }, + { + "packet_id": "vedicastro_kp_cusp_batch_probe", + "path": "references/oracle/vedicastro_kp_cusp_batch_probe_2026_07_19.json", + "domain": "kp_precision_timing", + "claim_status": "observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "VedicAstro KP cusp batch raw/hash over public cases; public numeric oracle still pending." + }, + { + "packet_id": "d1_d60_public_source_candidate_queue", + "path": "references/oracle/d1_d60_public_source_candidate_queue_2026_07_19.json", + "domain": "varga_mapping", + "claim_status": "open_queue", + "consumer_policy": "research_queue_only", + "claim_boundary": "Public D1-D60 source text candidates only; not numeric oracle." + }, + { + "packet_id": "muhurta_oss_factor_scoring_queue", + "path": "references/oracle/muhurta_oss_factor_scoring_queue_2026_07_19.json", + "domain": "muhurta", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "OSS Muhurta scoring surfaces queued; no final verdict until license/formula/examples/negative controls close." + }, + { + "packet_id": "four_engine_comparison_queue", + "path": "references/oracle/four_engine_comparison_queue_2026_07_19.json", + "domain": "four_engine_comparison", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "Four-engine queue prepared; requires shared input and unit/schema mapping; no majority-vote truth." + }, + { + "packet_id": "shadbala_component_closure_matrix", + "path": "references/oracle/shadbala_component_closure_matrix_2026_07_19.json", + "domain": "shadbala", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "42 Shadbala component rows have jyotishganit raw and Xalen/VP Jain/provenance hooks; same-unit absolute parity still pending." + }, + { + "packet_id": "shadbala_same_unit_normalizer", + "path": "references/oracle/shadbala_same_unit_normalizer_2026_07_19.json", + "domain": "shadbala_component_closure", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "Normalizes local/Xalen/jyotishganit/VP Jain Shadbala rows into Virupa/Rupa where available; classifications are arbitration queues, not absolute truth." + }, + { + "packet_id": "deep_fragment_oss_reuse_gate_round2", + "path": "references/oracle/deep_fragment_oss_reuse_gate_round2_2026_07_19.json", + "domain": "fragment_oss_reuse_governance", + "claim_status": "ready_contract", + "consumer_policy": "pre_work_required_before_sync", + "claim_boundary": "Current whole-machine fragment and OSS reuse guardrail is recorded; no numeric truth or production capability is upgraded by this packet." + }, { "packet_id": "shadbala_component_closure_queue_v2", "path": "references/oracle/shadbala_component_closure_queue_v2_2026_07_19.json", @@ -595,12 +531,60 @@ "claim_boundary": "42 same-unit Shadbala rows converted into field-level closure tickets; no absolute parity or majority-vote truth." }, { - "packet_id": "vedicastro_kp_api_probe_flatlib_tmp", - "path": "references/oracle/vedicastro_kp_api_probe_flatlib_tmp_2026_07_19.json", - "domain": "kp_precision_timing", - "claim_status": "observation_only", + "packet_id": "shadbala_component_closure_ledger", + "path": "references/oracle/shadbala_component_closure_ledger_2026_07_19.json", + "domain": "shadbala_component_closure", + "claim_status": "partial", "consumer_policy": "research_observation_only", - "claim_boundary": "Temporary flatlib import probe only; dependency version conflicts prevent runtime truth promotion." + "claim_boundary": "Classifies all 42 same-unit Shadbala rows into closed observation, method variant, or open mismatch; does not assert absolute formula truth." + }, + { + "packet_id": "muhurta_factor_scoring_readiness", + "path": "references/oracle/muhurta_factor_scoring_readiness_2026_07_19.json", + "domain": "muhurta_factor_scoring", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "Classifies Muhurta factors as supporting-context only; no scored verdict readiness until formulas, weights, public examples, and replay hashes close." + }, + { + "packet_id": "d1_d60_source_use_readiness", + "path": "references/oracle/d1_d60_source_use_readiness_2026_07_19.json", + "domain": "varga_mapping", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "Classifies D1-D60 sources for names/use notes only; generic-only Dn remains hidden until formula/source/oracle gates close." + }, + { + "packet_id": "day_level_holdout_readiness_ledger", + "path": "references/real_case_calibration/day_level_holdout_readiness_ledger_2026_07_19.json", + "domain": "timing_holdout", + "claim_status": "blocked", + "consumer_policy": "human_review_required", + "claim_boundary": "Reports frozen-label gap for day-level timing holdout; current pilot candidates are not independent labels and cannot unlock day/month claims." + }, + { + "packet_id": "three_engine_mismatch_progress_ledger", + "path": "references/oracle/three_engine_mismatch_progress_ledger_2026_07_19.json", + "domain": "three_engine_parity", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "Tracks explanation progress for mismatch rows; does not close original queue or allow majority-vote truth." + }, + { + "packet_id": "high_rigor_closure_gate_snapshot_2026_07_20", + "path": "references/oracle/high_rigor_closure_gate_snapshot_2026_07_20.json", + "domain": "high_rigor_governance", + "claim_status": "blocked", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Aggregates current high-rigor gates; blocked/partial gates remain blocked until their evidence packets close." + }, + { + "packet_id": "external_source_use_tier_registry_2026_07_20", + "path": "references/oracle/external_source_use_tier_registry_2026_07_20.json", + "domain": "external_source_governance", + "claim_status": "ready_contract", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Classifies OSS/web/real-case sources by allowed use tier; does not upgrade any source to oracle truth." }, { "packet_id": "blocked_domain_resolution_queue_2026_07_20", @@ -633,14 +617,222 @@ "claim_status": "open_queue", "consumer_policy": "research_to_commercial_contract_only", "claim_boundary": "Progress dashboard for five blocked-but-code-progressable domains; does not unblock production-ready claims." + }, + { + "packet_id": "compatibility_skill_readiness_dashboard", + "path": "references/oracle/compatibility_skill_readiness_dashboard_2026_07_20.json", + "domain": "compatibility", + "claim_status": "partial", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Basic compatibility layers are callable with boundaries; full synastry, relationship AV overlays, Planet/Lagna Kuta, and deterministic relationship timing remain blocked until oracle packets close." + }, + { + "packet_id": "worked_example_packet_intake_plan_2026_07_20", + "path": "references/oracle/worked_example_packet_intake_plan_2026_07_20.json", + "domain": "worked_example_collection", + "claim_status": "open_queue", + "consumer_policy": "research_observation_only", + "claim_boundary": "Groups KP, Muhurta, and Shadbala worked-example candidates into intake queues; no candidate is upgraded until numeric expected values, raw/hash, and replay comparison are archived." + }, + { + "packet_id": "three_engine_worked_example_bridge_2026_07_20", + "path": "references/oracle/three_engine_worked_example_bridge_2026_07_20.json", + "domain": "three_engine_parity", + "claim_status": "open_queue", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Links three-engine owner tracks to worked-example intake domains; no mismatch row is closed without replay or method-variant attribution." + }, + { + "packet_id": "public_worked_example_source_triage_2026_07_20", + "path": "references/oracle/public_worked_example_source_triage_2026_07_20.json", + "domain": "worked_example_collection", + "claim_status": "source_intake_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Triage of public KP, Muhurta, and Shadbala source candidates; no source is oracle-ready until raw capture, exact settings, expected values, hash, and replay comparison are archived." + }, + { + "packet_id": "muhurta_numeric_candidate_capture_packet_2026_07_20", + "path": "references/oracle/muhurta_numeric_candidate_capture_packet_2026_07_20.json", + "domain": "muhurta_factor_scoring", + "claim_status": "source_intake_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Stages two Muhurta numeric candidates for raw page capture; does not calculate, replay, or validate Muhurta verdicts." + }, + { + "packet_id": "real_case_website_e2e_eval_2026_07_20", + "path": "references/real_case_calibration/real_case_website_e2e_eval_2026_07_20.json", + "domain": "real_case_website_e2e", + "claim_status": "ready_contract", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "20 public real-case website E2E quality contract; product QA only, not an accuracy benchmark or independent holdout." + }, + { + "packet_id": "prashna_input_contract_2026_07_20", + "path": "references/oracle/prashna_input_contract_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "ready_contract", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Prashna input contract only; requires explicit time/place/timezone/ayanamsa/node, not predictive truth." + }, + { + "packet_id": "prashna_numeric_oracle_packet_queue_2026_07_20", + "path": "references/oracle/prashna_numeric_oracle_packet_queue_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "open_queue", + "consumer_policy": "research_to_commercial_contract_only", + "claim_boundary": "Public numeric Prashna/Sphuta candidates remain open queue until complete input/settings/raw hash/replay close." + }, + { + "packet_id": "prashna_sphuta_candidate_replay_readiness_2026_07_20", + "path": "references/oracle/prashna_sphuta_candidate_replay_readiness_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "tooling_observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Expected-value arithmetic replay shows Trisphuta agreement but Chatusphuta/Panchasphuta mismatch; complete Prashna input and legal external replay still required." + }, + { + "packet_id": "prashna_sphuta_mismatch_arbitration_2026_07_20", + "path": "references/oracle/prashna_sphuta_mismatch_arbitration_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "open_queue", + "consumer_policy": "research_observation_only", + "claim_boundary": "Queues Trisphuta/Chatusphuta/Panchasphuta mismatch causes and second public source candidate; no formula tuning or truth upgrade." + }, + { + "packet_id": "prashna_marga_raw_capture_packet_2026_07_20", + "path": "references/oracle/prashna_marga_raw_capture_packet_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "source_intake_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Pins Internet Archive Prasna Marga metadata and file hashes for later raw excerpt capture; no book text vendored and no truth upgrade." + }, + { + "packet_id": "prashna_marga_excerpt_locator_2026_07_20", + "path": "references/oracle/prashna_marga_excerpt_locator_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "source_intake_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Locates short Prasna Marga Sphuta/Gulika source windows with hashes; no long text vendored and no oracle truth upgrade." + }, + { + "packet_id": "prashna_sphuta_source_comparison_matrix_2026_07_20", + "path": "references/oracle/prashna_sphuta_source_comparison_matrix_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "open_queue", + "consumer_policy": "research_observation_only", + "claim_boundary": "Field-level matrix links VedAstro expected values, local formula replay and IA excerpt hashes; truth remains blocked until transcription/input/replay close." + }, + { + "packet_id": "prashna_sphuta_line_review_queue_2026_07_20", + "path": "references/oracle/prashna_sphuta_line_review_queue_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "open_queue", + "consumer_policy": "research_observation_only", + "claim_boundary": "Line-level review queue for Prasna Marga Sphuta windows; requires human/second-source transcription before formula or truth changes." + }, + { + "packet_id": "prashna_sphuta_review_result_template_2026_07_20", + "path": "references/oracle/prashna_sphuta_review_result_template_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "blocked_until_human_labels", + "consumer_policy": "human_review_required", + "claim_boundary": "Blank result template for Sphuta line review; no classification, replay, or truth upgrade until reviewer fields are filled." + }, + { + "packet_id": "prashna_sphuta_review_result_validation_2026_07_20", + "path": "references/oracle/prashna_sphuta_review_result_validation_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "blocked_until_human_labels", + "consumer_policy": "human_review_required", + "claim_boundary": "Validates Sphuta review templates; blank reviews remain blocked and completed reviews still require Prashna input and replay before truth upgrade." + }, + { + "packet_id": "prashna_sphuta_closure_dashboard_2026_07_20", + "path": "references/oracle/prashna_sphuta_closure_dashboard_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "blocked_until_human_labels", + "consumer_policy": "research_observation_only", + "claim_boundary": "Closure dashboard for Prashna/Sphuta packet chain; human review, complete input and legal external replay remain blocked." + }, + { + "packet_id": "prashna_sphuta_oss_case_probe_2026_07_20", + "path": "references/oracle/prashna_sphuta_oss_case_probe_2026_07_20.json", + "domain": "horary_annual_sensitive_points", + "claim_status": "tooling_observation_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Runs installed PyJHora/JHora OSS Sphuta case as isolated observation; AGPL implementation not vendored and no oracle truth upgraded." + }, + { + "packet_id": "oss_worked_example_source_matrix_2026_07_20", + "path": "references/oracle/oss_worked_example_source_matrix_2026_07_20.json", + "domain": "worked_example_collection", + "claim_status": "source_intake_only", + "consumer_policy": "research_observation_only", + "claim_boundary": "Open-source/public worked-example source matrix only; no source upgrades truth until license/version/raw hash/input contract/replay comparison are archived." + }, + { + "packet_id": "research_web_skill_commercial_gap_registry_2026_07_20", + "path": "references/oracle/research_web_skill_commercial_gap_registry_2026_07_20.json", + "domain": "research_web_skill_sync", + "claim_status": "open_queue", + "consumer_policy": "research_only_no_commercial_runtime_import", + "claim_boundary": "Compares research web/skill maturity with commercial UI patterns; may guide local research UX only and does not import Supabase, credits, payments, dirty commercial code, or truth upgrades." + }, + { + "packet_id": "kp_external_table_hash_manifest_2026_07_20", + "path": "references/oracle/kp_external_table_hash_manifest_2026_07_20.json", + "domain": "kp_precision_timing", + "claim_status": "blocked", + "consumer_policy": "research_observation_only", + "claim_boundary": "Current repo lacks legal pinned KP_SL_Divisions.csv fixture; exact KP cusp and timing oracle remain blocked until table hash and replay evidence are archived." + }, + { + "packet_id": "shadbala_naisargika_closure_packet_2026_07_20", + "path": "references/oracle/shadbala_naisargika_closure_packet_2026_07_20.json", + "domain": "shadbala_component_closure", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "Freezes Naisargikabala same-unit within-tolerance observation across five sources for one chart; no absolute Shadbala parity or production tuning upgrade." + }, + { + "packet_id": "shadbala_chesta_variant_packet_2026_07_20", + "path": "references/oracle/shadbala_chesta_variant_packet_2026_07_20.json", + "domain": "shadbala_component_closure", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "Splits Cheshtabala into luminary policy, mean-motion/Seeghrochcha method variants, and Venus formula/unit mismatch; no absolute Shadbala parity upgrade." + }, + { + "packet_id": "shadbala_digbala_formula_packet_2026_07_20", + "path": "references/oracle/shadbala_digbala_formula_packet_2026_07_20.json", + "domain": "shadbala_component_closure", + "claim_status": "partial", + "consumer_policy": "research_observation_only", + "claim_boundary": "Classifies Digbala rows as formula/unit mismatch and routes them to angular-reference formula arbitration; no absolute parity upgrade." + }, + { + "packet_id": "fragment_second_pass_candidate_ledger_2026_07_21", + "path": "references/oracle/fragment_second_pass_candidate_ledger_2026_07_21.json", + "domain": "fragment_governance", + "claim_status": "open_queue", + "consumer_policy": "research_only_no_direct_fragment_copy", + "claim_boundary": "Second-pass ledger for five high-value fragments; each candidate is migrate-to-test/registry, reference-only, or forbidden/private/obsolete. No wholesale copy or truth upgrade." + }, + { + "packet_id": "event_judgment_fragment_rule_family_registry_2026_07_21", + "path": "references/oracle/event_judgment_fragment_rule_family_registry_2026_07_21.json", + "domain": "event_judgment", + "claim_status": "ready_contract", + "consumer_policy": "research_registry_only_no_runtime_copy", + "claim_boundary": "Ports WorkBuddy event judgment as rule-family inventory only; no old engine code copied." + }, + { + "packet_id": "research_birth_time_journey_ui_contract_2026_07_21", + "path": "references/oracle/research_birth_time_journey_ui_contract_2026_07_21.json", + "domain": "research_web_birth_time_journey", + "claim_status": "ready_contract", + "consumer_policy": "research_local_ui_contract_only", + "claim_boundary": "Ports commercial birth-time journey behavior contracts only; no Supabase, credits, auth, or commercial code imported." } - ], - "production_tuning_allowed": false, - "scope": "evidence_packet_index", - "status": "active_index_v1", - "summary": { - "packet_count": 79, - "blocked_or_partial_count": 52, - "human_review_required_count": 2 - } + ] } diff --git a/references/oracle/fragment_second_pass_candidate_ledger_2026_07_21.json b/references/oracle/fragment_second_pass_candidate_ledger_2026_07_21.json new file mode 100644 index 00000000..2482819c --- /dev/null +++ b/references/oracle/fragment_second_pass_candidate_ledger_2026_07_21.json @@ -0,0 +1,72 @@ +{ + "scope": "fragment_second_pass_candidate_ledger", + "created_at": "2026-07-21", + "claim_status": "open_queue", + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "allowed_decisions": [ + "migrate_to_research_test_or_registry", + "reference_only", + "forbidden_private_or_obsolete" + ], + "boundary": "Second-pass precision audit of five high-value fragment candidates only. No file is copied wholesale; reusable value must become a research test/registry, reference-only citation, or forbidden/private/obsolete marker.", + "candidates": [ + { + "candidate_id": "workbuddy_event_judgment_engine", + "source_path": "/Users/wuyongnaren/.workbuddy/backups/jyotish-vedic-astrology-20260711-154109/scripts/event_judgment_engine.py", + "sha256_prefix": "f982bfd539fb1213", + "decision": "migrate_to_research_test_or_registry", + "value": "Contains event-route evidence ledger pattern: promise/activation/manifestation/timing, Vimshottari+Narayana timing, D9/UL for marriage, D10/A10 for career, D2/D10/AV for wealth.", + "migration_plan": "Do not copy old engine. Convert its route/evidence-field inventory into research registry/tests that assert current event_judgment_skeleton and strict workflow retain the same mandatory evidence families.", + "risk": "Old WorkBuddy fork is far behind current main; risk terms include generic key/pending strings. Treat as design/reference inventory, not runtime source.", + "next_artifact": "event_judgment_fragment_rule_family_registry" + }, + { + "candidate_id": "workbuddy_shadbala_handan_operator_card", + "source_path": "/Users/wuyongnaren/WorkBuddy/2026-07-05-19-03-49/yinduzhanxing/docs/benchmark/shadbala_handan_raman_first_packet_operator_card.md", + "decision": "forbidden_private_or_obsolete", + "value": "Shows an oracle-packet operator workflow pattern, but the named Handan/Raman personal packet is not a reusable public numeric oracle.", + "migration_plan": "Do not migrate content. If useful, recreate a blank operator-card template from current public-oracle packet schema without personal identifiers.", + "risk": "Contains named personal-case context and source/consent uncertainty; cannot be used as research truth or commercial input.", + "next_artifact": "none" + }, + { + "candidate_id": "workbuddy_first_shadbala_packet_assistant", + "source_path": "/Users/wuyongnaren/WorkBuddy/2026-07-05-19-03-49/yinduzhanxing/docs/benchmark/first_shadbala_oracle_packet_assistant.json", + "decision": "forbidden_private_or_obsolete", + "value": "Potential packet-assistant structure is superseded by current evidence_packet_index and Shadbala component closure packets.", + "migration_plan": "Do not migrate raw JSON. Preserve only the requirement that Shadbala packets need source, input, unit, hash and replay fields, already covered by current packet gates.", + "risk": "Linked to first/Handan packet chain and may include non-public or insufficiently sourced case metadata.", + "next_artifact": "none" + }, + { + "candidate_id": "commercial_birth_time_journey_tests", + "source_path": "/private/tmp/jyotisha-commercial-readonly.adiUr0/frontend/tests/birth-time-journey*.test.ts", + "decision": "migrate_to_research_test_or_registry", + "value": "Mature UX test patterns: resume state, projected baseline action for legacy snapshot, turn persistence, mobile scroll contract, dynamic stop policy, candidate completion and user-facing error contracts.", + "migration_plan": "Recreate research-local UI tests that assert profile mode hides chat composer, journey state resumes locally, candidate claims stay exploratory, and no Supabase/credits runtime is imported.", + "risk": "Commercial repo is dirty and includes business runtime. Copy no code; port only behavior contracts into localStorage/research-mode tests.", + "next_artifact": "research_birth_time_journey_ui_contract_tests" + }, + { + "candidate_id": "vedicastro_kp_source_table_candidate", + "source_path": "/Users/wuyongnaren/Documents/印度占星/references/open_source_sources/VedicAstro/vedicastro/VedicAstro.py", + "decision": "reference_only", + "value": "Useful KP API surface reference: get_rl_nl_sl_data, houses/planets data, SubLord/SubSubLord fields.", + "migration_plan": "Keep as pinned source/API reference only. Continue requiring legal KP_SL_Divisions.csv table hash and public worked example before exact KP cusp oracle upgrade.", + "risk": "Runtime dependency mismatch and table fixture missing; source/API surface does not prove event timing truth.", + "next_artifact": "kp_external_table_hash_manifest_2026_07_20" + } + ], + "summary": { + "candidate_count": 5, + "migrate_to_research_test_or_registry_count": 2, + "reference_only_count": 1, + "forbidden_private_or_obsolete_count": 2 + }, + "next_actions": [ + "Create event_judgment_fragment_rule_family_registry from the WorkBuddy engine inventory without copying implementation.", + "Create research-local birth-time journey UI contract tests based on commercial behavior, not commercial code.", + "Keep Handan/first packet materials forbidden unless replaced by public, consent-safe numeric examples." + ] +} diff --git a/references/oracle/kp_external_table_hash_manifest_2026_07_20.json b/references/oracle/kp_external_table_hash_manifest_2026_07_20.json new file mode 100644 index 00000000..6025310e --- /dev/null +++ b/references/oracle/kp_external_table_hash_manifest_2026_07_20.json @@ -0,0 +1,15 @@ +{ + "scope": "kp_external_table_hash_manifest", + "created_at": "2026-07-20", + "table_id": "VedicAstro_KP_SL_Divisions", + "expected_path": "references/open_source_sources/VedicAstro/vedicastro/data/KP_SL_Divisions.csv", + "source_candidate": "https://github.com/diliprk/VedicAstro", + "status": "fixture_missing", + "claim_status": "blocked_fixture_missing", + "sha256": null, + "row_count": 0, + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "claim_boundary": "Current repository does not contain the external KP sub/sub-lord table fixture. KP exact cusp and event timing cannot be upgraded until a legal source table is pinned with hash and replay evidence.", + "next_action": "pin legal source table file or keep KP exact cusp oracle blocked" +} diff --git a/references/oracle/muhurta_numeric_candidate_capture_packet_2026_07_20.json b/references/oracle/muhurta_numeric_candidate_capture_packet_2026_07_20.json new file mode 100644 index 00000000..2cea47b8 --- /dev/null +++ b/references/oracle/muhurta_numeric_candidate_capture_packet_2026_07_20.json @@ -0,0 +1,75 @@ +{ + "boundary": "Capture packet staging only; does not calculate or validate Muhurta verdicts.", + "capture_rows": [ + { + "canonical_request_hash": "5bcdcf51cdd8a7bb42a3fab019eaffacf3a562904f69c25b65c1ba559b41f602", + "claim_boundary": "Numeric-looking public source; not oracle-ready until raw page, exact settings, hash, and local replay comparison are archived.", + "domain": "muhurta_factor_scoring", + "missing_for_oracle": [ + "raw_capture_hash", + "exact_date_selection", + "timezone", + "sunrise", + "formula_weight_contract", + "exact_method_settings", + "replay_comparison" + ], + "next_artifact_path": "references/oracle/artifacts/mypanchang_edison_2025_panchangam_raw_capture_packet.json", + "observed_numeric_fields": [ + "tarabalam periods", + "chandrabalam periods", + "nakshatra", + "rasi", + "tithi", + "yoga", + "karana" + ], + "raw_capture_status": "pending_raw_page_capture", + "source_id": "mypanchang_edison_2025_panchangam", + "source_observation_hash": "d57a30a4da43bb5ee92f16b6d8e2f6df099854a1cab1a2f95f72efea16dd9671", + "topic": "Tarabala/Chandrabala/Panchangam daily factors", + "upgrade_status": "not_oracle_ready", + "url": "https://www.mypanchang.com/phppanchang.php?cityhead=&cityname=Edison-NJ&mn=04&monthtype=1&yr=2025" + }, + { + "canonical_request_hash": "b6110c7ab7a6346f42671a1c46fb6d81286483ea2e9a3808dd02367742ed06df", + "claim_boundary": "Numeric-looking public source; not oracle-ready until raw page, exact settings, hash, and local replay comparison are archived.", + "domain": "muhurta_factor_scoring", + "missing_for_oracle": [ + "raw_capture_hash", + "sunrise", + "sunset", + "timezone", + "calculation_rule_replay", + "exact_method_settings", + "replay_comparison" + ], + "next_artifact_path": "references/oracle/artifacts/drikpanchang_mumbai_rahu_2026_07_20_raw_capture_packet.json", + "observed_numeric_fields": [ + "rahu kalam interval", + "weekday", + "city/date scoped daily table" + ], + "raw_capture_status": "pending_raw_page_capture", + "source_id": "drikpanchang_mumbai_rahu_2026_07_20", + "source_observation_hash": "a7a4c1f5ba3fe872af645cddab962eba9fe4e0f697eee5d3bfca73951a571a8a", + "topic": "Rahu Kalam daily interval", + "upgrade_status": "not_oracle_ready", + "url": "https://www.drikpanchang.com/muhurat/rahu-kalam.html?date=20/07/2026&geoname-id=1275339" + } + ], + "claim_status": "source_intake_only", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "scope": "muhurta_numeric_candidate_capture_packet", + "sources": { + "source_triage": "references/oracle/public_worked_example_source_triage_2026_07_20.json" + }, + "status": "capture_packet_ready", + "summary": { + "candidate_count": 2, + "oracle_ready_count": 0, + "pending_raw_capture_count": 2 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/oss_worked_example_source_matrix_2026_07_20.json b/references/oracle/oss_worked_example_source_matrix_2026_07_20.json new file mode 100644 index 00000000..908a49e4 --- /dev/null +++ b/references/oracle/oss_worked_example_source_matrix_2026_07_20.json @@ -0,0 +1,108 @@ +{ + "scope": "oss_worked_example_source_matrix", + "created_at": "2026-07-20", + "claim_status": "source_intake_only", + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "boundary": "Source matrix for reusable open-source/public worked-example candidates. A row can feed numeric oracle packets only after license, version/commit, raw capture hash, input contract, and replay comparison are archived.", + "sources": [ + { + "source_id": "pyjhora_pvr_tests", + "name": "naturalstupid/PyJHora JHora pvr_tests", + "url": "https://github.com/naturalstupid/PyJHora", + "license_status": "AGPL_or_strong_copyleft_observation_only", + "candidate_domains": [ + "prashna_sphuta", + "shadbala", + "varga", + "dasha" + ], + "case_usefulness": "Installed package includes executable tests such as sphuta_tests with concrete birth data and expected textual values.", + "local_status": "installed_runtime_observation_available", + "numeric_packet_status": "one_sphuta_case_probe_created", + "reuse_policy": "black_box_observation_only", + "promotion_boundary": "May provide external observation/raw/hash only; do not vendor code or copy formulas into runtime." + }, + { + "source_id": "jyotishganit_github", + "name": "northtara/jyotishganit", + "url": "https://github.com/northtara/jyotishganit", + "license_status": "MIT_candidate_verify_commit_license", + "candidate_domains": [ + "panchanga", + "varga", + "ashtakavarga", + "shadbala_surface" + ], + "case_usefulness": "Already used as field-level raw probe for D2/D4/D9/D10, Panchanga, BAV/SAV; Shadbala API surface still incomplete.", + "local_status": "installed_or_local_mirror_available", + "numeric_packet_status": "field_probe_ready_not_truth", + "reuse_policy": "permissive_candidate_adapter_preferred", + "promotion_boundary": "Can be adapter/observation source after commit/hash/license and same-input raw are pinned; does not resolve formula variants alone." + }, + { + "source_id": "vedicastro_kp_runtime", + "name": "diliprk/VedicAstro", + "url": "https://github.com/diliprk/VedicAstro", + "license_status": "repo_license_and_file_license_must_be_verified_before_copy", + "candidate_domains": [ + "kp_precision_timing" + ], + "case_usefulness": "Runtime/API surface can emit KP house cusp/star/sub/sub-sub raw for batch probes.", + "local_status": "temporary_flatlib_polars_probe_available", + "numeric_packet_status": "runtime_raw_available_but_public_worked_example_needed", + "reuse_policy": "isolated_subprocess_observation_until_license_clear", + "promotion_boundary": "KP exact cusp may advance only after public numeric worked example replay matches pinned raw." + }, + { + "source_id": "fusionstrings_panchangam", + "name": "fusionstrings/panchangam", + "url": "https://github.com/fusionstrings/panchangam", + "license_status": "permissive_candidate_verify_repo_license", + "candidate_domains": [ + "muhurta_factor_scoring", + "panchanga" + ], + "case_usefulness": "Open-source Panchangam candidate for Tarabala/Chandrabala/Rahu Kalam/Abhijit style factor comparison.", + "local_status": "not_imported", + "numeric_packet_status": "candidate_requires_pin_install_and_raw_hash", + "reuse_policy": "adapter_candidate_no_truth_upgrade", + "promotion_boundary": "Observation-only until exact date/place/timezone, sunrise source, and factor scoring weights are pinned." + }, + { + "source_id": "bidyashish_panchang", + "name": "bidyashish/panchang", + "url": "https://github.com/bidyashish/panchang", + "license_status": "public_repo_license_verify_before_use", + "candidate_domains": [ + "panchanga", + "muhurta_factor_scoring" + ], + "case_usefulness": "Candidate secondary Panchanga implementation for cross-checking weekday/tithi/nakshatra/yoga/karana and Rahu-type intervals.", + "local_status": "not_imported", + "numeric_packet_status": "candidate_requires_pin_install_and_raw_hash", + "reuse_policy": "adapter_candidate_no_truth_upgrade", + "promotion_boundary": "Do not produce Muhurta verdicts until factor weights and negative examples are validated." + }, + { + "source_id": "kp_sub_lord_boundary_tables", + "name": "KP star/sub lord boundary tables from open-source CSV/API surfaces", + "url": "https://github.com/diliprk/VedicAstro", + "license_status": "table_file_license_and_hash_required", + "candidate_domains": [ + "kp_precision_timing" + ], + "case_usefulness": "Boundary tables can validate star/sub/sub-sub segmentation independently of event interpretation.", + "local_status": "partial_table_probe_exists", + "numeric_packet_status": "candidate_requires_raw_capture_hash", + "reuse_policy": "hash_tables_then_adapter_only", + "promotion_boundary": "Segmentation correctness is not event-timing correctness; worked examples and holdout are still required." + } + ], + "next_actions": [ + "Pin source commit/package hash before each runtime probe.", + "Use local mirrors or temporary isolated installs; do not add runtime dependencies to research app unless explicitly promoted.", + "Upgrade only rows with concrete numeric inputs/outputs into numeric oracle packets.", + "Keep AGPL/GPL sources observation-only and outside commercial runtime." + ] +} diff --git a/references/oracle/prashna_input_contract_2026_07_20.json b/references/oracle/prashna_input_contract_2026_07_20.json new file mode 100644 index 00000000..429b8422 --- /dev/null +++ b/references/oracle/prashna_input_contract_2026_07_20.json @@ -0,0 +1,42 @@ +{ + "claim_boundary": "Input contract only; does not validate Prashna predictions or external numeric parity.", + "claim_status": "ready_contract", + "created_at": "2026-07-20", + "optional_fields": [ + "question_text", + "querent_id", + "house_focus", + "language" + ], + "production_tuning_allowed": false, + "required_fields": [ + { + "boundary": "exact time question is received/accepted", + "field": "question_datetime_local", + "format": "YYYY-MM-DDTHH:MM:SS" + }, + { + "boundary": "place of querent/astrologer must be explicit", + "field": "location", + "format": "lat/lon + place label" + }, + { + "boundary": "no implicit local machine timezone", + "field": "timezone", + "format": "IANA or UTC offset" + }, + { + "boundary": "default must be recorded, e.g. Lahiri", + "field": "ayanamsa", + "format": "named sidereal ayanamsa" + }, + { + "boundary": "Rahu/Ketu mode must be frozen", + "field": "node_mode", + "format": "mean|true" + } + ], + "scope": "prashna_input_contract", + "status": "contract_ready", + "truth_matrix_allowed": false +} diff --git a/references/oracle/prashna_marga_excerpt_locator_2026_07_20.json b/references/oracle/prashna_marga_excerpt_locator_2026_07_20.json new file mode 100644 index 00000000..4b54dd9b --- /dev/null +++ b/references/oracle/prashna_marga_excerpt_locator_2026_07_20.json @@ -0,0 +1,54 @@ +{ + "blocked_files": [], + "boundary": "Short context and hashes only; no long copyrighted text is reproduced and no truth upgrade is allowed.", + "claim_status": "source_intake_only", + "created_at": "2026-07-20", + "located_windows": [ + { + "download_url": "https://archive.org/download/PrasnaMargaBVR/Prasna%20Marga%201_djvu.txt", + "file_name": "Prasna Marga 1_djvu.txt", + "line_end": 143, + "line_start": 139, + "matched_line": 141, + "matched_terms": [ + "Gulika" + ], + "short_context": "53. The Moon’s Longitude ... 154 54. Position of Gulika 159 55. Thrisphuta... 162", + "source_id": "PrasnaMargaBVR", + "window_hash": "9b512ea3157ce3f8b24f30a9f7d6ded1a9eb97dc77462dc27cac269ace2f5a52" + }, + { + "download_url": "https://archive.org/download/prasna-marga-part-2-by-bv-raman/Prasna%20Marga%20Part%202%20by%20BV%20Raman_djvu.txt", + "file_name": "Prasna Marga Part 2 by BV Raman_djvu.txt", + "line_end": 1161, + "line_start": 1157, + "matched_line": 1159, + "matched_terms": [ + "Gulika" + ], + "short_context": "Stanza 19. If Yama Sukra (...) or the lord of the 10th house from him occupies the 6th, the 8th or the 12th from Arudha or if Venus occupies the 6th, the 8th or the 12th from Yama Sukra, marriage will not take place at the time fixed. If Ra", + "source_id": "prasna-marga-part-2-by-bv-raman", + "window_hash": "84f1b543641d873e4737333ab9243632f4d7f191b67bf716d3e8616bcc2a10dd" + } + ], + "missing_for_oracle": [ + "complete_prashna_input", + "raw excerpt capture", + "line-level transcription review", + "legal external replay" + ], + "next_steps": [ + "raw excerpt capture with page/line coordinates and independent transcription review", + "compare VedAstro vs B.V. Raman wording before formula tuning" + ], + "production_tuning_allowed": false, + "scope": "prashna_marga_excerpt_locator", + "status": "excerpt_locator_ready", + "summary": { + "blocked_file_count": 0, + "located_window_count": 2, + "oracle_ready_count": 0 + }, + "truth_matrix_allowed": false, + "upgrade_status": "candidate_not_oracle" +} diff --git a/references/oracle/prashna_marga_raw_capture_packet_2026_07_20.json b/references/oracle/prashna_marga_raw_capture_packet_2026_07_20.json new file mode 100644 index 00000000..bef7ab69 --- /dev/null +++ b/references/oracle/prashna_marga_raw_capture_packet_2026_07_20.json @@ -0,0 +1,66 @@ +{ + "boundary": "Capture metadata only; long copyrighted text is not reproduced.", + "claim_status": "source_intake_only", + "created_at": "2026-07-20", + "field_locator_terms": [ + "Trisphuta", + "Chatusphuta", + "Catusphuta", + "Panchasphuta", + "Gulika" + ], + "internet_archive_items": [ + { + "claim_boundary": "Metadata/file hash pin only; no book text is vendored and no numeric truth is upgraded.", + "files": [ + { + "download_url": "https://archive.org/download/PrasnaMargaBVR/Prasna%20Marga%201_djvu.txt", + "format": "DjVuTXT", + "name": "Prasna Marga 1_djvu.txt", + "sha1": "bed3491a79ca5039409dac7fd62e386f7de55a47" + }, + { + "download_url": "https://archive.org/download/PrasnaMargaBVR/Prasna%20Marga%201.pdf", + "format": "Text PDF", + "name": "Prasna Marga 1.pdf", + "sha1": "86839fc2d13509309ec3a14a160c861bb223d844" + } + ], + "identifier": "PrasnaMargaBVR", + "metadata_url": "https://archive.org/metadata/PrasnaMargaBVR", + "source_metadata_hash": "a36100f19e1f6440c2bae22d9cb45f7535e94f6956fac1c5d13b90dd3e6989fb", + "title": "Prasna Marga - Dr. BV Raman", + "upgrade_status": "candidate_not_oracle" + }, + { + "claim_boundary": "Metadata/file hash pin only; no book text is vendored and no numeric truth is upgraded.", + "files": [ + { + "download_url": "https://archive.org/download/prasna-marga-part-2-by-bv-raman/Prasna%20Marga%20Part%202%20by%20BV%20Raman_djvu.txt", + "format": "DjVuTXT", + "name": "Prasna Marga Part 2 by BV Raman_djvu.txt", + "sha1": "17c432465520ef75bde7a2c4fa167ff119664666" + } + ], + "identifier": "prasna-marga-part-2-by-bv-raman", + "metadata_url": "https://archive.org/metadata/prasna-marga-part-2-by-bv-raman", + "source_metadata_hash": "faaeb9c95112f48480d8b79023c83a7ac1a101c7865b4ead1085dd3a16974dfe", + "title": "Prasna Marga Part 2 By BV Raman", + "upgrade_status": "candidate_not_oracle" + } + ], + "next_steps": [ + "raw excerpt capture around locator terms with page/line coordinates", + "compare B.V. Raman scan vs VedAstro transcription", + "only then classify formula_variant vs source_transcription" + ], + "production_tuning_allowed": false, + "scope": "prashna_marga_raw_capture_packet", + "status": "raw_capture_metadata_ready", + "summary": { + "ia_item_count": 2, + "oracle_ready_count": 0, + "pinned_file_count": 3 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/prashna_numeric_oracle_packet_queue_2026_07_20.json b/references/oracle/prashna_numeric_oracle_packet_queue_2026_07_20.json new file mode 100644 index 00000000..e851d6ed --- /dev/null +++ b/references/oracle/prashna_numeric_oracle_packet_queue_2026_07_20.json @@ -0,0 +1,46 @@ +{ + "boundary": "Queue only; no Prashna/Saham/Gulika/Sphuta claim is upgraded until complete input, raw/hash and local/external replay close.", + "claim_status": "open_queue", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "rows": [ + { + "candidate_hash": "2f8fafd98fba0f328b9eba58af1b4446ebf3a856d0769c79d6dc27e99218c63d", + "claim_boundary": "Numeric Sphuta example exists, but full Prashna input/settings are incomplete; use as candidate only.", + "domain": "horary_annual_sensitive_points", + "expected_values": { + "chatusphuta": "2s 15° 18' 34\"", + "gulika": "3s 14° 10'", + "lagna": "3s 27° 22'", + "moon": "3s 19° 36' 34\"", + "panchasphuta": "5s 23° 34' 34\"", + "rahu": "3s 8° 16'", + "sun": "4s 3° 8' 25\"", + "trisphuta": "11s 1° 8' 34\"" + }, + "missing_for_oracle": [ + "complete_prashna_input", + "ayanamsa", + "node_mode", + "timezone", + "raw_capture_hash", + "local_replay", + "pyjhora_or_other_legal_replay" + ], + "numeric_fields_present": true, + "source_id": "vedastro_prasna_marga_ch5_sphuta_example", + "source_role": "public_numeric_candidate", + "technique_family": "sphuta_trisphuta_family", + "upgrade_status": "candidate_not_oracle", + "url": "https://vedastro.org/book/PrasnaMarga/Chapter5" + } + ], + "scope": "prashna_numeric_oracle_packet_queue", + "status": "queue_ready", + "summary": { + "candidate_count": 1, + "numeric_candidate_count": 1, + "oracle_ready_count": 0 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/prashna_sphuta_candidate_replay_readiness_2026_07_20.json b/references/oracle/prashna_sphuta_candidate_replay_readiness_2026_07_20.json new file mode 100644 index 00000000..539e5977 --- /dev/null +++ b/references/oracle/prashna_sphuta_candidate_replay_readiness_2026_07_20.json @@ -0,0 +1,48 @@ +{ + "boundary": "Expected-value arithmetic only; complete Prashna inputs are still required for oracle replay.", + "claim_status": "tooling_observation_only", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "rows": [ + { + "claim_boundary": "Checks arithmetic consistency of published expected values only; not a true local ephemeris replay.", + "computed_from_expected_degrees": { + "chatusphuta": 94.28305555555556, + "panchasphuta": 192.54972222222221, + "trisphuta": 331.1427777777778 + }, + "expected_degrees": { + "chatusphuta": 75.30944444444444, + "gulika": 104.16666666666667, + "lagna": 117.36666666666666, + "moon": 109.60944444444443, + "panchasphuta": 173.57611111111112, + "rahu": 98.26666666666667, + "sun": 123.14027777777778, + "trisphuta": 331.1427777777778 + }, + "local_formula_consistency": "mismatch", + "missing_for_true_replay": [ + "question_datetime_local", + "location", + "timezone", + "ayanamsa", + "node_mode", + "raw_capture_hash", + "legal_external_replay" + ], + "replay_status": "blocked_missing_complete_input", + "source_id": "vedastro_prasna_marga_ch5_sphuta_example", + "upgrade_status": "not_oracle_ready", + "url": "https://vedastro.org/book/PrasnaMarga/Chapter5" + } + ], + "scope": "prashna_sphuta_candidate_replay_readiness", + "status": "replay_readiness_ready", + "summary": { + "candidate_count": 1, + "local_formula_check_pass_count": 0, + "oracle_ready_count": 0 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/prashna_sphuta_closure_dashboard_2026_07_20.json b/references/oracle/prashna_sphuta_closure_dashboard_2026_07_20.json new file mode 100644 index 00000000..81826b6e --- /dev/null +++ b/references/oracle/prashna_sphuta_closure_dashboard_2026_07_20.json @@ -0,0 +1,109 @@ +{ + "boundary": "Dashboard only; summarizes blocked gates and does not upgrade any Prashna/Sphuta claim.", + "claim_status": "blocked_until_human_labels", + "commercial_sync_status": "research_observation_only", + "created_at": "2026-07-20", + "forbidden_uses": [ + "do_not_use_for_deterministic_prashna_verdict", + "do_not_tune_formula_from_candidate_mismatch", + "do_not_claim_external_oracle_ready" + ], + "gates": [ + { + "evidence": "review_result_validation valid_completed_review_count == 0", + "gate_id": "human_line_review", + "status": "blocked" + }, + { + "evidence": "candidate lacks question datetime/location/timezone/ayanamsa/node", + "gate_id": "complete_prashna_input", + "status": "blocked" + }, + { + "evidence": "no PyJHora/other legal replay packet yet", + "gate_id": "legal_external_replay", + "status": "blocked" + }, + { + "evidence": "Trisphuta matches; Chatusphuta/Panchasphuta mismatch", + "gate_id": "formula_or_transcription_arbitration", + "status": "open_queue" + } + ], + "next_actions": [ + "fill review_result_template via human/second-source review", + "capture complete Prashna input if a worked example is found", + "run legal external replay only after inputs close" + ], + "packet_chain": [ + { + "claim_status": "ready_contract", + "path": "references/oracle/prashna_input_contract_2026_07_20.json", + "scope": "prashna_input_contract", + "status": "contract_ready" + }, + { + "claim_status": "open_queue", + "path": "references/oracle/prashna_numeric_oracle_packet_queue_2026_07_20.json", + "scope": "prashna_numeric_oracle_packet_queue", + "status": "queue_ready" + }, + { + "claim_status": "tooling_observation_only", + "path": "references/oracle/prashna_sphuta_candidate_replay_readiness_2026_07_20.json", + "scope": "prashna_sphuta_candidate_replay_readiness", + "status": "replay_readiness_ready" + }, + { + "claim_status": "open_queue", + "path": "references/oracle/prashna_sphuta_mismatch_arbitration_2026_07_20.json", + "scope": "prashna_sphuta_mismatch_arbitration", + "status": "arbitration_queue_ready" + }, + { + "claim_status": "source_intake_only", + "path": "references/oracle/prashna_marga_raw_capture_packet_2026_07_20.json", + "scope": "prashna_marga_raw_capture_packet", + "status": "raw_capture_metadata_ready" + }, + { + "claim_status": "source_intake_only", + "path": "references/oracle/prashna_marga_excerpt_locator_2026_07_20.json", + "scope": "prashna_marga_excerpt_locator", + "status": "excerpt_locator_ready" + }, + { + "claim_status": "open_queue", + "path": "references/oracle/prashna_sphuta_source_comparison_matrix_2026_07_20.json", + "scope": "prashna_sphuta_source_comparison_matrix", + "status": "comparison_matrix_ready" + }, + { + "claim_status": "open_queue", + "path": "references/oracle/prashna_sphuta_line_review_queue_2026_07_20.json", + "scope": "prashna_sphuta_line_review_queue", + "status": "review_queue_ready" + }, + { + "claim_status": "blocked_until_human_labels", + "path": "references/oracle/prashna_sphuta_review_result_template_2026_07_20.json", + "scope": "prashna_sphuta_review_result_template", + "status": "blank_review_template_ready" + }, + { + "claim_status": "blocked_until_human_labels", + "path": "references/oracle/prashna_sphuta_review_result_validation_2026_07_20.json", + "scope": "prashna_sphuta_review_result_validation", + "status": "validation_ready" + } + ], + "production_tuning_allowed": false, + "scope": "prashna_sphuta_closure_dashboard", + "status": "closure_dashboard_ready", + "summary": { + "blocked_gate_count": 3, + "packet_chain_count": 10, + "truth_upgrade_count": 0 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/prashna_sphuta_line_review_queue_2026_07_20.json b/references/oracle/prashna_sphuta_line_review_queue_2026_07_20.json new file mode 100644 index 00000000..dab542d5 --- /dev/null +++ b/references/oracle/prashna_sphuta_line_review_queue_2026_07_20.json @@ -0,0 +1,70 @@ +{ + "acceptance_criteria": [ + "do_not_copy_long_text", + "record_line_coordinates", + "classify_formula_variant_or_transcription", + "preserve_window_hash", + "require_second_source_or_scan_review" + ], + "boundary": "Human/second-source transcription queue only.", + "claim_status": "open_queue", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "review_tasks": [ + { + "candidate_causes": [ + "formula_variant", + "source_transcription", + "naming_variant" + ], + "claim_boundary": "Review task only; no formula change or truth upgrade.", + "download_url": "https://archive.org/download/PrasnaMargaBVR/Prasna%20Marga%201_djvu.txt", + "fields_to_check": [ + "trisphuta", + "chatusphuta", + "catusphuta", + "panchasphuta", + "gulika" + ], + "file_name": "Prasna Marga 1_djvu.txt", + "line_end": 143, + "line_start": 139, + "review_status": "needs_human_or_second_source_review", + "short_context": "53. The Moon’s Longitude ... 154 54. Position of Gulika 159 55. Thrisphuta... 162", + "source_id": "PrasnaMargaBVR", + "task_id": "PSLRQ-001", + "window_hash": "9b512ea3157ce3f8b24f30a9f7d6ded1a9eb97dc77462dc27cac269ace2f5a52" + }, + { + "candidate_causes": [ + "formula_variant", + "source_transcription", + "naming_variant" + ], + "claim_boundary": "Review task only; no formula change or truth upgrade.", + "download_url": "https://archive.org/download/prasna-marga-part-2-by-bv-raman/Prasna%20Marga%20Part%202%20by%20BV%20Raman_djvu.txt", + "fields_to_check": [ + "trisphuta", + "chatusphuta", + "catusphuta", + "panchasphuta", + "gulika" + ], + "file_name": "Prasna Marga Part 2 by BV Raman_djvu.txt", + "line_end": 1161, + "line_start": 1157, + "review_status": "needs_human_or_second_source_review", + "short_context": "Stanza 19. If Yama Sukra (...) or the lord of the 10th house from him occupies the 6th, the 8th or the 12th from Arudha or if Venus occupies the 6th, the 8th or the 12th from Yama Sukra, marriage will not take place at the time fixed. If Ra", + "source_id": "prasna-marga-part-2-by-bv-raman", + "task_id": "PSLRQ-002", + "window_hash": "84f1b543641d873e4737333ab9243632f4d7f191b67bf716d3e8616bcc2a10dd" + } + ], + "scope": "prashna_sphuta_line_review_queue", + "status": "review_queue_ready", + "summary": { + "review_task_count": 2, + "truth_upgrade_count": 0 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/prashna_sphuta_mismatch_arbitration_2026_07_20.json b/references/oracle/prashna_sphuta_mismatch_arbitration_2026_07_20.json new file mode 100644 index 00000000..7e1d630a --- /dev/null +++ b/references/oracle/prashna_sphuta_mismatch_arbitration_2026_07_20.json @@ -0,0 +1,52 @@ +{ + "boundary": "Queue records candidate causes; closure requires raw source and replay evidence.", + "claim_status": "open_queue", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "rows": [ + { + "candidate_causes": [ + "formula_variant", + "source_transcription", + "chatusphuta_catusphuta_naming", + "incomplete_input_settings" + ], + "chatusphuta_status": "mismatch", + "claim_boundary": "Mismatch queue only; do not tune formulas or upgrade Prashna truth from this packet.", + "next_evidence": [ + "raw scan/page capture", + "complete example input", + "independent translation/transcription check", + "legal external replay" + ], + "next_evidence_owner": "worked_example_collection", + "panchasphuta_status": "mismatch", + "source_id": "vedastro_prasna_marga_ch5_sphuta_example", + "trisphuta_status": "matches", + "upgrade_status": "not_oracle_ready", + "url": "https://vedastro.org/book/PrasnaMarga/Chapter5" + } + ], + "scope": "prashna_sphuta_mismatch_arbitration", + "source_candidates": [ + { + "source_id": "vedastro_prasna_marga_ch5_sphuta_example", + "source_role": "numeric_candidate", + "upgrade_status": "candidate_not_oracle", + "url": "https://vedastro.org/book/PrasnaMarga/Chapter5" + }, + { + "source_id": "internet_archive_prasna_marga_bv_raman_sphuta_fragment", + "source_role": "public_formula_numeric_fragment_candidate", + "upgrade_status": "candidate_not_oracle", + "url": "https://archive.org/details/PrasnaMarga/" + } + ], + "status": "arbitration_queue_ready", + "summary": { + "mismatch_count": 1, + "oracle_ready_count": 0, + "source_candidate_count": 2 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/prashna_sphuta_oss_case_probe_2026_07_20.json b/references/oracle/prashna_sphuta_oss_case_probe_2026_07_20.json new file mode 100644 index 00000000..b9b1b369 --- /dev/null +++ b/references/oracle/prashna_sphuta_oss_case_probe_2026_07_20.json @@ -0,0 +1,55 @@ +{ + "boundary": "Runs installed OSS package only; no AGPL implementation is copied and no oracle truth is upgraded.", + "case": { + "dob": "1996-12-07", + "place": "Chennai 13.0878,80.2785 +05:30", + "source": "jhora.tests.pvr_tests.sphuta_tests", + "tob": "10:34:00" + }, + "claim_status": "tooling_observation_only", + "created_at": "2026-07-20", + "license_boundary": "agpl_observation_only_do_not_vendor", + "oracle_ready": false, + "package_metadata": { + "captured_import_stderr_hash": "12ae32cb1ec02d01eda3581b127c1fee3b0dc53572ed6baf239721a03d82e126", + "captured_import_stdout_hash": "68877a75b8cc1d658297fed91817bc2dea2c3dd406e44133fd09ba0d4cf9cedb", + "license": "GNU AFFERO GENERAL PUBLIC LICENSE\n Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. \n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n Preamble\n\n The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works. By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n When we speak of free software, we are referring to freedom, not\nprice. Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate. Many developers of free software are heartened and\nencouraged by the resulting cooperation. However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community. It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server. Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals. This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n The precise terms and conditions for copying, distribution and\nmodification follow.\n\n TERMS AND CONDITIONS\n\n 0. Definitions.\n\n \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n \"The Program\" refers to any copyrightable work licensed under this\nLicense. Each licensee is addressed as \"you\". \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy. The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy. Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies. Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License. If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n 1. Source Code.\n\n The \"source code\" for a work means the preferred form of the work\nfor making modifications to it. \"Object code\" means any non-source\nform of a work.\n\n A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form. A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities. However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work. For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n The Corresponding Source for a work in source code form is that\nsame work.\n\n 2. Basic Permissions.\n\n All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met. This License explicitly affirms your unlimited\npermission to run the unmodified Program. The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work. This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force. You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright. Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n Conveying under any other circumstances is permitted solely under\nthe conditions stated below. Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n 3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n 4. Conveying Verbatim Copies.\n\n You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n 5. Conveying Modified Source Versions.\n\n You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n a) The work must carry prominent notices stating that you modified\n it, and giving a relevant date.\n\n b) The work must carry prominent notices stating that it is\n released under this License and any conditions added under section\n 7. This requirement modifies the requirement in section 4 to\n \"keep intact all notices\".\n\n c) You must license the entire work, as a whole, under this\n License to anyone who comes into possession of a copy. This\n License will therefore apply, along with any applicable section 7\n additional terms, to the whole of the work, and all its parts,\n regardless of how they are packaged. This License gives no\n permission to license the work in any other way, but it does not\n invalidate such permission if you have separately received it.\n\n d) If the work has interactive user interfaces, each must display\n Appropriate Legal Notices; however, if the Program has interactive\n interfaces that do not display Appropriate Legal Notices, your\n work need not make them do so.\n\n A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit. Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n 6. Conveying Non-Source Forms.\n\n You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n a) Convey the object code in, or embodied in, a physical product\n (including a physical distribution medium), accompanied by the\n Corresponding Source fixed on a durable physical medium\n customarily used for software interchange.\n\n b) Convey the object code in, or embodied in, a physical product\n (including a physical distribution medium), accompanied by a\n written offer, valid for at least three years and valid for as\n long as you offer spare parts or customer support for that product\n model, to give anyone who possesses the object code either (1) a\n copy of the Corresponding Source for all the software in the\n product that is covered by this License, on a durable physical\n medium customarily used for software interchange, for a price no\n more than your reasonable cost of physically performing this\n conveying of source, or (2) access to copy the\n Corresponding Source from a network server at no charge.\n\n c) Convey individual copies of the object code with a copy of the\n written offer to provide the Corresponding Source. This\n alternative is allowed only occasionally and noncommercially, and\n only if you received the object code with such an offer, in accord\n with subsection 6b.\n\n d) Convey the object code by offering access from a designated\n place (gratis or for a charge), and offer equivalent access to the\n Corresponding Source in the same way through the same place at no\n further charge. You need not require recipients to copy the\n Corresponding Source along with the object code. If the place to\n copy the object code is a network server, the Corresponding Source\n may be on a different server (operated by you or a third party)\n that supports equivalent copying facilities, provided you maintain\n clear directions next to the object code saying where to find the\n Corresponding Source. Regardless of what server hosts the\n Corresponding Source, you remain obligated to ensure that it is\n available for as long as needed to satisfy these requirements.\n\n e) Convey the object code using peer-to-peer transmission, provided\n you inform other peers where the object code and Corresponding\n Source of the work are being offered to the general public at no\n charge under subsection 6d.\n\n A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling. In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage. For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product. A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source. The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information. But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed. Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n 7. Additional Terms.\n\n \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law. If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit. (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.) You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n a) Disclaiming warranty or limiting liability differently from the\n terms of sections 15 and 16 of this License; or\n\n b) Requiring preservation of specified reasonable legal notices or\n author attributions in that material or in the Appropriate Legal\n Notices displayed by works containing it; or\n\n c) Prohibiting misrepresentation of the origin of that material, or\n requiring that modified versions of such material be marked in\n reasonable ways as different from the original version; or\n\n d) Limiting the use for publicity purposes of names of licensors or\n authors of the material; or\n\n e) Declining to grant rights under trademark law for use of some\n trade names, trademarks, or service marks; or\n\n f) Requiring indemnification of licensors and authors of that\n material by anyone who conveys the material (or modified versions of\n it) with contractual assumptions of liability to the recipient, for\n any liability that these contractual assumptions directly impose on\n those licensors and authors.\n\n All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10. If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term. If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n 8. Termination.\n\n You may not propagate or modify a covered work except as expressly\nprovided under this License. Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License. If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n 9. Acceptance Not Required for Having Copies.\n\n You are not required to accept this License in order to receive or\nrun a copy of the Program. Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance. However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work. These actions infringe copyright if you do\nnot accept this License. Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n 10. Automatic Licensing of Downstream Recipients.\n\n Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License. You are not responsible\nfor enforcing compliance by third parties with this License.\n\n An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations. If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License. For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n 11. Patents.\n\n A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based. The\nwork thus licensed is called the contributor's \"contributor version\".\n\n A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version. For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement). To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients. \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License. You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n 12. No Surrender of Others' Freedom.\n\n If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License. If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all. For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n 13. Remote Network Interaction; Use with the GNU General Public License.\n\n Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software. This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work. The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n 14. Revised Versions of this License.\n\n The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time. Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n Each version is given a distinguishing version number. If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation. If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n Later license versions may give you additional or different\npermissions. However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n 15. Disclaimer of Warranty.\n\n THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n 16. Limitation of Liability.\n\n IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n 17. Interpretation of Sections 15 and 16.\n\n If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n END OF TERMS AND CONDITIONS\n\n How to Apply These Terms to Your New Programs\n\n If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n To do so, attach the following notices to the program. It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n \n Copyright (C) \n\n This program is free software: you can redistribute it and/or modify\n it under the terms of the GNU Affero General Public License as published\n by the Free Software Foundation, either version 3 of the License, or\n (at your option) any later version.\n\n This program is distributed in the hope that it will be useful,\n but WITHOUT ANY WARRANTY; without even the implied warranty of\n MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n GNU Affero General Public License for more details.\n\n You should have received a copy of the GNU Affero General Public License\n along with this program. If not, see .\n\nAlso add information on how to contact you by electronic and paper mail.\n\n If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source. For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code. There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n.", + "module_file": "/Library/Frameworks/Python.framework/Versions/3.11/lib/python3.11/site-packages/jhora/__init__.py", + "package": "PyJHora", + "version": "4.8.7" + }, + "production_tuning_allowed": false, + "raw_hash": "cf320936d95149682afc2ce00596cce295d169f3944de355458767eb85049553", + "rows": [ + { + "expected_from_oss_case": "Pisces 20° 47’ 20\"", + "field": "tri_sphuta", + "raw_result": [ + 11, + 18.139340510382908 + ], + "status": "observed" + }, + { + "expected_from_oss_case": "Scorpio 12° 21’ 15\"", + "field": "chatur_sphuta", + "raw_result": [ + 7, + 8.821421240176619 + ], + "status": "observed" + }, + { + "expected_from_oss_case": "Aries 22° 54’ 29\"", + "field": "pancha_sphuta", + "raw_result": [ + 0, + 19.639046190771296 + ], + "status": "observed" + } + ], + "scope": "prashna_sphuta_oss_case_probe", + "status": "oss_probe_ready", + "truth_matrix_allowed": false +} diff --git a/references/oracle/prashna_sphuta_review_result_template_2026_07_20.json b/references/oracle/prashna_sphuta_review_result_template_2026_07_20.json new file mode 100644 index 00000000..b1042ab8 --- /dev/null +++ b/references/oracle/prashna_sphuta_review_result_template_2026_07_20.json @@ -0,0 +1,97 @@ +{ + "boundary": "Blank template only; human review fields must be filled before any classification or formula change.", + "claim_status": "blocked_until_human_labels", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "scope": "prashna_sphuta_review_result_template", + "status": "blank_review_template_ready", + "summary": { + "completed_review_count": 0, + "template_count": 2, + "truth_upgrade_count": 0 + }, + "templates": [ + { + "allowed_results": [ + "formula_variant", + "source_transcription", + "naming_variant", + "insufficient_evidence" + ], + "candidate_causes": [ + "formula_variant", + "source_transcription", + "naming_variant" + ], + "claim_boundary": "Review task only; no formula change or truth upgrade.", + "completed": false, + "download_url": "https://archive.org/download/PrasnaMargaBVR/Prasna%20Marga%201_djvu.txt", + "fields_to_check": [ + "trisphuta", + "chatusphuta", + "catusphuta", + "panchasphuta", + "gulika" + ], + "file_name": "Prasna Marga 1_djvu.txt", + "line_end": 143, + "line_start": 139, + "required_human_fields": [ + "reviewer_id", + "reviewed_at", + "source_line_coordinates", + "second_source_or_scan_evidence", + "review_notes" + ], + "review_result": null, + "review_status": "needs_human_or_second_source_review", + "short_context": "53. The Moon’s Longitude ... 154 54. Position of Gulika 159 55. Thrisphuta... 162", + "source_id": "PrasnaMargaBVR", + "task_id": "PSLRQ-001", + "upgrade_after_completion": "requires_replay_packet_and_gate_review", + "window_hash": "9b512ea3157ce3f8b24f30a9f7d6ded1a9eb97dc77462dc27cac269ace2f5a52" + }, + { + "allowed_results": [ + "formula_variant", + "source_transcription", + "naming_variant", + "insufficient_evidence" + ], + "candidate_causes": [ + "formula_variant", + "source_transcription", + "naming_variant" + ], + "claim_boundary": "Review task only; no formula change or truth upgrade.", + "completed": false, + "download_url": "https://archive.org/download/prasna-marga-part-2-by-bv-raman/Prasna%20Marga%20Part%202%20by%20BV%20Raman_djvu.txt", + "fields_to_check": [ + "trisphuta", + "chatusphuta", + "catusphuta", + "panchasphuta", + "gulika" + ], + "file_name": "Prasna Marga Part 2 by BV Raman_djvu.txt", + "line_end": 1161, + "line_start": 1157, + "required_human_fields": [ + "reviewer_id", + "reviewed_at", + "source_line_coordinates", + "second_source_or_scan_evidence", + "review_notes" + ], + "review_result": null, + "review_status": "needs_human_or_second_source_review", + "short_context": "Stanza 19. If Yama Sukra (...) or the lord of the 10th house from him occupies the 6th, the 8th or the 12th from Arudha or if Venus occupies the 6th, the 8th or the 12th from Yama Sukra, marriage will not take place at the time fixed. If Ra", + "source_id": "prasna-marga-part-2-by-bv-raman", + "task_id": "PSLRQ-002", + "upgrade_after_completion": "requires_replay_packet_and_gate_review", + "window_hash": "84f1b543641d873e4737333ab9243632f4d7f191b67bf716d3e8616bcc2a10dd" + } + ], + "truth_matrix_allowed": false, + "upgrade_policy": "no_upgrade_until_completed_review_and_replay" +} diff --git a/references/oracle/prashna_sphuta_review_result_validation_2026_07_20.json b/references/oracle/prashna_sphuta_review_result_validation_2026_07_20.json new file mode 100644 index 00000000..d0f7a125 --- /dev/null +++ b/references/oracle/prashna_sphuta_review_result_validation_2026_07_20.json @@ -0,0 +1,53 @@ +{ + "allowed_results": [ + "formula_variant", + "source_transcription", + "naming_variant", + "insufficient_evidence" + ], + "boundary": "Blank templates remain blocked; completed review alone still cannot upgrade truth without replay.", + "claim_status": "blocked_until_human_labels", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "replay_gate_policy": "requires_valid_completed_review_and_complete_prashna_input", + "scope": "prashna_sphuta_review_result_validation", + "status": "validation_ready", + "summary": { + "replay_gate_ready_count": 0, + "template_count": 2, + "valid_completed_review_count": 0 + }, + "truth_matrix_allowed": false, + "validation_rows": [ + { + "claim_boundary": "Validation only; replay gate also requires complete Prashna input.", + "missing_fields": [ + "review_result", + "reviewer_id", + "reviewed_at", + "source_line_coordinates", + "second_source_or_scan_evidence", + "review_notes" + ], + "replay_gate_ready": false, + "review_result": null, + "task_id": "PSLRQ-001", + "validation_status": "blocked_missing_human_review" + }, + { + "claim_boundary": "Validation only; replay gate also requires complete Prashna input.", + "missing_fields": [ + "review_result", + "reviewer_id", + "reviewed_at", + "source_line_coordinates", + "second_source_or_scan_evidence", + "review_notes" + ], + "replay_gate_ready": false, + "review_result": null, + "task_id": "PSLRQ-002", + "validation_status": "blocked_missing_human_review" + } + ] +} diff --git a/references/oracle/prashna_sphuta_source_comparison_matrix_2026_07_20.json b/references/oracle/prashna_sphuta_source_comparison_matrix_2026_07_20.json new file mode 100644 index 00000000..1b21552b --- /dev/null +++ b/references/oracle/prashna_sphuta_source_comparison_matrix_2026_07_20.json @@ -0,0 +1,87 @@ +{ + "boundary": "Matrix connects VedAstro expected values, local arithmetic replay, and IA locator hashes; still open queue.", + "claim_status": "open_queue", + "created_at": "2026-07-20", + "field_rows": [ + { + "claim_boundary": "Field comparison only; no Prashna truth upgrade without complete input/raw/replay.", + "field": "sun", + "ia_excerpt_status": "not_field_specific", + "local_formula_degree": null, + "local_vs_vedastro_status": "input_value_only", + "vedastro_expected_degree": 123.14027777777778 + }, + { + "claim_boundary": "Field comparison only; no Prashna truth upgrade without complete input/raw/replay.", + "field": "moon", + "ia_excerpt_status": "not_field_specific", + "local_formula_degree": null, + "local_vs_vedastro_status": "input_value_only", + "vedastro_expected_degree": 109.60944444444443 + }, + { + "claim_boundary": "Field comparison only; no Prashna truth upgrade without complete input/raw/replay.", + "field": "lagna", + "ia_excerpt_status": "not_field_specific", + "local_formula_degree": null, + "local_vs_vedastro_status": "input_value_only", + "vedastro_expected_degree": 117.36666666666666 + }, + { + "claim_boundary": "Field comparison only; no Prashna truth upgrade without complete input/raw/replay.", + "field": "gulika", + "ia_excerpt_status": "located_context", + "local_formula_degree": null, + "local_vs_vedastro_status": "input_value_only", + "vedastro_expected_degree": 104.16666666666667 + }, + { + "claim_boundary": "Field comparison only; no Prashna truth upgrade without complete input/raw/replay.", + "field": "rahu", + "ia_excerpt_status": "not_field_specific", + "local_formula_degree": null, + "local_vs_vedastro_status": "input_value_only", + "vedastro_expected_degree": 98.26666666666667 + }, + { + "claim_boundary": "Field comparison only; no Prashna truth upgrade without complete input/raw/replay.", + "field": "trisphuta", + "ia_excerpt_status": "located_context", + "local_formula_degree": 331.1427777777778, + "local_vs_vedastro_status": "match", + "vedastro_expected_degree": 331.1427777777778 + }, + { + "claim_boundary": "Field comparison only; no Prashna truth upgrade without complete input/raw/replay.", + "field": "chatusphuta", + "ia_excerpt_status": "located_context", + "local_formula_degree": 94.28305555555556, + "local_vs_vedastro_status": "mismatch", + "vedastro_expected_degree": 75.30944444444444 + }, + { + "claim_boundary": "Field comparison only; no Prashna truth upgrade without complete input/raw/replay.", + "field": "panchasphuta", + "ia_excerpt_status": "located_context", + "local_formula_degree": 192.54972222222221, + "local_vs_vedastro_status": "mismatch", + "vedastro_expected_degree": 173.57611111111112 + } + ], + "ia_excerpt_window_count": 2, + "next_evidence": [ + "line-level transcription review", + "complete Prashna input", + "legal external replay" + ], + "production_tuning_allowed": false, + "scope": "prashna_sphuta_source_comparison_matrix", + "status": "comparison_matrix_ready", + "summary": { + "field_count": 8, + "match_count": 1, + "mismatch_count": 2, + "truth_upgrade_count": 0 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/public_worked_example_source_triage_2026_07_20.json b/references/oracle/public_worked_example_source_triage_2026_07_20.json new file mode 100644 index 00000000..8f8f66a6 --- /dev/null +++ b/references/oracle/public_worked_example_source_triage_2026_07_20.json @@ -0,0 +1,123 @@ +{ + "boundary": "Public source triage only. Numeric candidates require raw capture and replay before becoming oracle packets.", + "claim_status": "source_intake_only", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "scope": "public_worked_example_source_triage", + "sources": [ + { + "claim_boundary": "Source triage only; do not upgrade until raw page capture, exact settings, expected numeric values, hash, and replay comparison are archived.", + "domain": "muhurta_factor_scoring", + "missing_for_oracle": [ + "raw_capture_hash", + "exact_date_selection", + "timezone", + "sunrise", + "formula_weight_contract" + ], + "numeric_fields_present": true, + "observation_hash": "d57a30a4da43bb5ee92f16b6d8e2f6df099854a1cab1a2f95f72efea16dd9671", + "observed_numeric_fields": [ + "tarabalam periods", + "chandrabalam periods", + "nakshatra", + "rasi", + "tithi", + "yoga", + "karana" + ], + "source_id": "mypanchang_edison_2025_panchangam", + "source_role": "numeric_candidate", + "topic": "Tarabala/Chandrabala/Panchangam daily factors", + "upgrade_status": "candidate_not_oracle", + "url": "https://www.mypanchang.com/phppanchang.php?cityhead=&cityname=Edison-NJ&mn=04&monthtype=1&yr=2025" + }, + { + "claim_boundary": "Source triage only; do not upgrade until raw page capture, exact settings, expected numeric values, hash, and replay comparison are archived.", + "domain": "muhurta_factor_scoring", + "missing_for_oracle": [ + "raw_capture_hash", + "sunrise", + "sunset", + "timezone", + "calculation_rule_replay" + ], + "numeric_fields_present": true, + "observation_hash": "a7a4c1f5ba3fe872af645cddab962eba9fe4e0f697eee5d3bfca73951a571a8a", + "observed_numeric_fields": [ + "rahu kalam interval", + "weekday", + "city/date scoped daily table" + ], + "source_id": "drikpanchang_mumbai_rahu_2026_07_20", + "source_role": "numeric_candidate", + "topic": "Rahu Kalam daily interval", + "upgrade_status": "candidate_not_oracle", + "url": "https://www.drikpanchang.com/muhurat/rahu-kalam.html?date=20/07/2026&geoname-id=1275339" + }, + { + "claim_boundary": "Source triage only; do not upgrade until raw page capture, exact settings, expected numeric values, hash, and replay comparison are archived.", + "domain": "muhurta_factor_scoring", + "missing_for_oracle": [ + "birth_moon_nakshatra", + "current_moon_nakshatra", + "worked_numeric_example", + "raw_capture_hash" + ], + "numeric_fields_present": false, + "observation_hash": "26b314e3d7d2fb1dfa91e82e4d92e966a3aa3f43a04f423a9a36c08cf1b0925f", + "observed_numeric_fields": [], + "source_id": "mypanchang_tarabalam_chakra", + "source_role": "formula_reference", + "topic": "Tarabalam/Chandrabalam formula reference", + "upgrade_status": "candidate_not_oracle", + "url": "https://www.mypanchang.com/tarabalam.php" + }, + { + "claim_boundary": "Source triage only; do not upgrade until raw page capture, exact settings, expected numeric values, hash, and replay comparison are archived.", + "domain": "kp_precision_timing", + "missing_for_oracle": [ + "public_birth_or_query_input", + "cusp_longitude", + "star_lord", + "sub_lord", + "sub_sub_lord", + "raw_capture_hash" + ], + "numeric_fields_present": false, + "observation_hash": "bae793bb25145c8f716937c4c4dd575c60307dc1eb0a01f007b452e1907c0705", + "observed_numeric_fields": [], + "source_id": "astrosage_kp_cuspal_sub_lord", + "source_role": "runtime_or_reference_candidate", + "topic": "KP cuspal sub lord calculator/reference", + "upgrade_status": "candidate_not_oracle", + "url": "https://www.astrosage.com/kp/cuspal-sub-lord.asp" + }, + { + "claim_boundary": "Source triage only; do not upgrade until raw page capture, exact settings, expected numeric values, hash, and replay comparison are archived.", + "domain": "shadbala_component_closure", + "missing_for_oracle": [ + "complete_birth_input", + "component_virupa_table", + "method_variant", + "raw_capture_hash" + ], + "numeric_fields_present": false, + "observation_hash": "70f17bcba9f127e2fe46a42c3830abc05acaf250085e796e0e21506fe099d160", + "observed_numeric_fields": [], + "source_id": "astrojyoti_shadbala_formula", + "source_role": "formula_reference", + "topic": "Shadbala formula reference", + "upgrade_status": "candidate_not_oracle", + "url": "https://www.astrojyoti.com/shadbala.htm" + } + ], + "status": "source_triage_ready", + "summary": { + "formula_reference_count": 2, + "numeric_candidate_count": 2, + "oracle_ready_count": 0, + "source_count": 5 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/research_birth_time_journey_ui_contract_2026_07_21.json b/references/oracle/research_birth_time_journey_ui_contract_2026_07_21.json new file mode 100644 index 00000000..65487d43 --- /dev/null +++ b/references/oracle/research_birth_time_journey_ui_contract_2026_07_21.json @@ -0,0 +1,44 @@ +{ + "scope": "research_birth_time_journey_ui_contract", + "created_at": "2026-07-21", + "claim_status": "ready_contract", + "source_fragment": "/private/tmp/jyotisha-commercial-readonly.adiUr0/frontend/tests/birth-time-journey*.test.ts", + "source_policy": "behavior_contract_only_no_commercial_code", + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "boundary": "Ports commercial birth-time journey maturity as research-local UI contracts only. No Supabase, credits, auth, queue, or commercial implementation is imported.", + "contracts": [ + { + "contract_id": "profile_mode_hides_chat_overlay", + "source_pattern": "mobile scroll/profile journey separation", + "research_requirement": "When user opens My Chart/profile form, bottom chat composer must disappear and the form must be centered." + }, + { + "contract_id": "new_chat_restores_chat_surface", + "source_pattern": "journey resume / turn state transitions", + "research_requirement": "New chat or starter prompt must restore chat mode and composer visibility." + }, + { + "contract_id": "candidate_claim_stays_exploratory", + "source_pattern": "candidate completion / response invariants", + "research_requirement": "Birth-time rectification result may show candidate ranges and scores, but must not claim birth-time truth." + }, + { + "contract_id": "local_resume_only", + "source_pattern": "journey turn persistence", + "research_requirement": "Research repo may persist local journey/profile state with localStorage only; no Supabase or credit dependency." + }, + { + "contract_id": "user_error_contract", + "source_pattern": "dynamic question validator / user errors", + "research_requirement": "Invalid dates, missing time/place, or unparseable choices must produce actionable local UI messages, not silent fake UI." + } + ], + "forbidden_imports": [ + "Supabase runtime", + "credits/payment/subscription logic", + "commercial auth/session business rules", + "dirty commercial worktree code", + "commercial user data" + ] +} diff --git a/references/oracle/research_web_skill_commercial_gap_registry_2026_07_20.json b/references/oracle/research_web_skill_commercial_gap_registry_2026_07_20.json new file mode 100644 index 00000000..b2df11d3 --- /dev/null +++ b/references/oracle/research_web_skill_commercial_gap_registry_2026_07_20.json @@ -0,0 +1,112 @@ +{ + "scope": "research_web_skill_commercial_gap_registry", + "created_at": "2026-07-20", + "claim_status": "open_queue", + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "boundary": "Research repo optimization plan only. Commercial UI patterns can be learned as product flow references, but research runtime must not import Supabase, credits, payment, or dirty commercial worktree code.", + "current_state": { + "research_repo": { + "strength": [ + "core Jyotish calculation and interpretation skill coverage", + "strict workflow and Technique Audit Table requirements", + "evidence packet index and claim-status governance", + "open-source observation probes for PyJHora/JHora, jyotishganit, VedicAstro, Xalen/Jyotishyamitra candidates" + ], + "web_maturity": "research_tooling_partial", + "web_current_entries": [ + "chart calculation and rendering", + "AI/chat bridge", + "rectification UI and engine", + "MEVG audit helpers", + "Tajika/transit/advanced analysis modules" + ], + "web_gap": [ + "profile onboarding is lighter than commercial chart-profile flow", + "local chart library and current-profile linkage need clearer UX", + "conversation/session management is less mature than commercial sidebar", + "rectification journey lacks commercial-grade resume/mobile-scroll/error-contract polish", + "skill truth overlay is stronger than web claim display; web should surface blocked/partial boundaries more explicitly" + ] + }, + "skill_layer": { + "strength": [ + "35 Dasha systems named with maturity caveats", + "405+ Yoga rule registry", + "D1-D144/generic divisional engine claims guarded by truth overlay", + "Prashna/KP/Muhurta/Tajika/Sphuta marked partial or blocked where needed" + ], + "gap": [ + "effective_skill_capability_view must be the only advertised capability source", + "web/API should consume skill_truth_overlay before showing technique confidence", + "high-rigor closure gates need visible UI/API claim boundaries", + "worked-example numeric packets should drive skill upgrades, not registry labels alone" + ] + }, + "commercial_repo_patterns_to_learn": { + "source_path": "/private/tmp/jyotisha-commercial-readonly.adiUr0", + "source_status": "read_only_dirty_worktree_do_not_copy_directly", + "valuable_patterns": [ + "chart profile persistence flow", + "birth-time journey resume and turn persistence", + "dynamic rectification question planner and stop policy", + "mobile scroll contract for rectification cards", + "sidebar session row actions and conversation organization", + "user-facing error contracts around invalid patterns/input" + ], + "must_not_import": [ + "Supabase runtime", + "credits/payment/subscription logic", + "commercial auth/session business rules", + "dirty uncommitted API/test changes", + "commercial user data" + ] + } + }, + "delivery_queue": [ + { + "track": "worked_examples_to_numeric_packets", + "status": "in_progress", + "next_delivery": "Convert eligible public/OSS candidates into raw-captured numeric packets only when exact input, expected numeric fields, license/version, and replay hash are available.", + "gate_for_upgrade": "oracle_ready_count > 0 in worked_example_numeric_packet_eligibility" + }, + { + "track": "component_closure", + "status": "in_progress", + "next_delivery": "Use Shadbala same-unit rows to close each of 42 components as within_tolerance, formula_mismatch, unit_mismatch, or method_variant.", + "gate_for_upgrade": "absolute_parity_ready_count increases without majority-vote truth" + }, + { + "track": "claim_gate_upgrade", + "status": "in_progress", + "next_delivery": "Wire high-rigor closure snapshot into user-facing/API claim boundary so blocked/partial cannot be displayed as verified.", + "gate_for_upgrade": "runtime output carries claim_status and production_tuning_allowed=false for partial techniques" + }, + { + "track": "research_web_profile_flow", + "status": "planned", + "next_delivery": "Adopt commercial-style chart profile onboarding with localStorage only: name, birth date/time, place, timezone, optional gender-role field, active profile selector.", + "gate_for_upgrade": "local profile save/load/clear and active-profile chart hash tests pass" + }, + { + "track": "research_web_rectification_journey", + "status": "planned", + "next_delivery": "Learn commercial journey patterns: resume state, candidate range card, mobile scroll contract, clearer invalid-input messages.", + "gate_for_upgrade": "rectification UI tests prove candidate claim remains exploratory, not birth-time truth" + }, + { + "track": "skill_to_web_sync", + "status": "planned", + "next_delivery": "Expose effective_skill_capability_view and skill_truth_overlay inside research web technique audit display.", + "gate_for_upgrade": "web audit table labels KP/Muhurta/Shadbala/Prashna according to overlay, not optimistic registry labels" + } + ], + "recommended_order": [ + "worked_examples_to_numeric_packets", + "component_closure", + "claim_gate_upgrade", + "research_web_profile_flow", + "research_web_rectification_journey", + "skill_to_web_sync" + ] +} diff --git a/references/oracle/shadbala_chesta_variant_packet_2026_07_20.json b/references/oracle/shadbala_chesta_variant_packet_2026_07_20.json new file mode 100644 index 00000000..27a50195 --- /dev/null +++ b/references/oracle/shadbala_chesta_variant_packet_2026_07_20.json @@ -0,0 +1,232 @@ +{ + "scope": "shadbala_chesta_variant_packet", + "created_at": "2026-07-20", + "component": "chesta", + "canonical_component": "Cheshtabala", + "status": "variant_packet_ready", + "claim_status": "partial", + "closure_classification": "method_variant_mixed_with_formula_mismatch", + "absolute_parity_ready": false, + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "source_queue": "references/oracle/shadbala_component_closure_queue_v2_2026_07_19.json", + "summary": { + "component_row_count": 7, + "method_variant_count": 6, + "formula_or_unit_mismatch_count": 1, + "within_tolerance_count": 0, + "max_delta_virupa": 108.76 + }, + "rows": [ + { + "ticket_id": "shadbala.sun.chesta", + "planet": "Sun", + "canonical_component": "Cheshtabala", + "normalized_values_virupa": { + "jyotishganit": 37.06, + "local": 36.0, + "vp_jain_local": 70.14, + "vp_jain_published": 0.0, + "xalen": 30.0 + }, + "max_delta_virupa": 70.14, + "closure_classification": "method_variant", + "variant_family": "luminary_chesta_policy_conflict", + "next_evidence_owner": "method_variant_decision", + "known_variants": [ + "luminary_chesta_policy", + "mean-motion vs apparent-speed", + "Seeghrochcha model" + ], + "unit_contract": "Virupa motional-strength component; speed basis, luminary policy, retrograde handling, and Seeghrochcha model must be explicit.", + "source_evidence": [ + "classical motional strength rules", + "mean-motion/Seeghrochcha requirement", + "VP Jain fixture variants" + ], + "closure_note": "Luminary Chesta policy diverges sharply: VP Jain published uses 0 while other engines keep non-zero or fixed values.", + "claim_boundary": "Do not tune Chesta to majority values. Keep method variants explicit until authoritative formula source and second public numeric case close." + }, + { + "ticket_id": "shadbala.moon.chesta", + "planet": "Moon", + "canonical_component": "Cheshtabala", + "normalized_values_virupa": { + "jyotishganit": 10.667, + "local": 21.34, + "vp_jain_local": 108.76, + "vp_jain_published": 0.0, + "xalen": 30.0 + }, + "max_delta_virupa": 108.76, + "closure_classification": "method_variant", + "variant_family": "luminary_chesta_policy_conflict", + "next_evidence_owner": "method_variant_decision", + "known_variants": [ + "luminary_chesta_policy", + "mean-motion vs apparent-speed", + "Seeghrochcha model" + ], + "unit_contract": "Virupa motional-strength component; speed basis, luminary policy, retrograde handling, and Seeghrochcha model must be explicit.", + "source_evidence": [ + "classical motional strength rules", + "mean-motion/Seeghrochcha requirement", + "VP Jain fixture variants" + ], + "closure_note": "Luminary Chesta policy diverges sharply: VP Jain published uses 0 while other engines keep non-zero or fixed values.", + "claim_boundary": "Do not tune Chesta to majority values. Keep method variants explicit until authoritative formula source and second public numeric case close." + }, + { + "ticket_id": "shadbala.mars.chesta", + "planet": "Mars", + "canonical_component": "Cheshtabala", + "normalized_values_virupa": { + "jyotishganit": 22.864, + "local": 21.6, + "vp_jain_local": 19.25, + "vp_jain_published": 20.93, + "xalen": 15.0 + }, + "max_delta_virupa": 7.864, + "closure_classification": "method_variant", + "variant_family": "mean_motion_seeghrochcha_variant", + "next_evidence_owner": "method_variant_decision", + "known_variants": [ + "luminary_chesta_policy", + "mean-motion vs apparent-speed", + "Seeghrochcha model" + ], + "unit_contract": "Virupa motional-strength component; speed basis, luminary policy, retrograde handling, and Seeghrochcha model must be explicit.", + "source_evidence": [ + "classical motional strength rules", + "mean-motion/Seeghrochcha requirement", + "VP Jain fixture variants" + ], + "closure_note": "Differences are consistent with speed basis/Seeghrochcha implementation variants; preserve variant instead of tuning to majority.", + "claim_boundary": "Do not tune Chesta to majority values. Keep method variants explicit until authoritative formula source and second public numeric case close." + }, + { + "ticket_id": "shadbala.mercury.chesta", + "planet": "Mercury", + "canonical_component": "Cheshtabala", + "normalized_values_virupa": { + "jyotishganit": 35.368, + "local": 47.8, + "vp_jain_local": 18.08, + "vp_jain_published": 28.76, + "xalen": 60.0 + }, + "max_delta_virupa": 41.92, + "closure_classification": "method_variant", + "variant_family": "mean_motion_seeghrochcha_variant", + "next_evidence_owner": "method_variant_decision", + "known_variants": [ + "luminary_chesta_policy", + "mean-motion vs apparent-speed", + "Seeghrochcha model" + ], + "unit_contract": "Virupa motional-strength component; speed basis, luminary policy, retrograde handling, and Seeghrochcha model must be explicit.", + "source_evidence": [ + "classical motional strength rules", + "mean-motion/Seeghrochcha requirement", + "VP Jain fixture variants" + ], + "closure_note": "Differences are consistent with speed basis/Seeghrochcha implementation variants; preserve variant instead of tuning to majority.", + "claim_boundary": "Do not tune Chesta to majority values. Keep method variants explicit until authoritative formula source and second public numeric case close." + }, + { + "ticket_id": "shadbala.jupiter.chesta", + "planet": "Jupiter", + "canonical_component": "Cheshtabala", + "normalized_values_virupa": { + "jyotishganit": 36.424, + "local": 46.49, + "vp_jain_local": 11.23, + "vp_jain_published": 8.43, + "xalen": 60.0 + }, + "max_delta_virupa": 51.57, + "closure_classification": "method_variant", + "variant_family": "mean_motion_seeghrochcha_variant", + "next_evidence_owner": "method_variant_decision", + "known_variants": [ + "luminary_chesta_policy", + "mean-motion vs apparent-speed", + "Seeghrochcha model" + ], + "unit_contract": "Virupa motional-strength component; speed basis, luminary policy, retrograde handling, and Seeghrochcha model must be explicit.", + "source_evidence": [ + "classical motional strength rules", + "mean-motion/Seeghrochcha requirement", + "VP Jain fixture variants" + ], + "closure_note": "Differences are consistent with speed basis/Seeghrochcha implementation variants; preserve variant instead of tuning to majority.", + "claim_boundary": "Do not tune Chesta to majority values. Keep method variants explicit until authoritative formula source and second public numeric case close." + }, + { + "ticket_id": "shadbala.venus.chesta", + "planet": "Venus", + "canonical_component": "Cheshtabala", + "normalized_values_virupa": { + "jyotishganit": 46.466, + "local": 29.36, + "vp_jain_local": 27.23, + "vp_jain_published": 28.18, + "xalen": 15.0 + }, + "max_delta_virupa": 31.466, + "closure_classification": "formula_or_unit_mismatch", + "variant_family": "mean_motion_seeghrochcha_formula_or_unit_exception", + "next_evidence_owner": "formula_source_arbitration", + "known_variants": [ + "luminary_chesta_policy", + "mean-motion vs apparent-speed", + "Seeghrochcha model" + ], + "unit_contract": "Virupa motional-strength component; speed basis, luminary policy, retrograde handling, and Seeghrochcha model must be explicit.", + "source_evidence": [ + "classical motional strength rules", + "mean-motion/Seeghrochcha requirement", + "VP Jain fixture variants" + ], + "closure_note": "Same broad Chesta method family, but current normalized values do not cluster enough to close as method variant only.", + "claim_boundary": "Do not tune Chesta to majority values. Keep method variants explicit until authoritative formula source and second public numeric case close." + }, + { + "ticket_id": "shadbala.saturn.chesta", + "planet": "Saturn", + "canonical_component": "Cheshtabala", + "normalized_values_virupa": { + "jyotishganit": 58.137, + "local": 36.99, + "vp_jain_local": 6.78, + "vp_jain_published": 5.05, + "xalen": 30.0 + }, + "max_delta_virupa": 53.087, + "closure_classification": "method_variant", + "variant_family": "mean_motion_seeghrochcha_variant", + "next_evidence_owner": "method_variant_decision", + "known_variants": [ + "luminary_chesta_policy", + "mean-motion vs apparent-speed", + "Seeghrochcha model" + ], + "unit_contract": "Virupa motional-strength component; speed basis, luminary policy, retrograde handling, and Seeghrochcha model must be explicit.", + "source_evidence": [ + "classical motional strength rules", + "mean-motion/Seeghrochcha requirement", + "VP Jain fixture variants" + ], + "closure_note": "Differences are consistent with speed basis/Seeghrochcha implementation variants; preserve variant instead of tuning to majority.", + "claim_boundary": "Do not tune Chesta to majority values. Keep method variants explicit until authoritative formula source and second public numeric case close." + } + ], + "boundary": "Cheshtabala is split into explicit variant families. This packet improves attribution but does not close absolute Shadbala Virupa parity or production prediction tuning.", + "next_actions": [ + "pin classical/VP Jain Chesta formula source line by line", + "add independent public Shadbala worked example with Chesta components", + "separate luminary policy from non-luminary mean-motion/Seeghrochcha implementation tests" + ], + "raw_hash": "558cf827ede9298df041e1d2d615096868b5d4e510a60f607a4942f5673e30b7" +} diff --git a/references/oracle/shadbala_digbala_formula_packet_2026_07_20.json b/references/oracle/shadbala_digbala_formula_packet_2026_07_20.json new file mode 100644 index 00000000..77319929 --- /dev/null +++ b/references/oracle/shadbala_digbala_formula_packet_2026_07_20.json @@ -0,0 +1,219 @@ +{ + "scope": "shadbala_digbala_formula_packet", + "created_at": "2026-07-20", + "component": "dig", + "canonical_component": "Digbala", + "status": "formula_packet_ready", + "claim_status": "partial", + "closure_classification": "formula_or_unit_mismatch", + "absolute_parity_ready": false, + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "source_queue": "references/oracle/shadbala_component_closure_queue_v2_2026_07_19.json", + "summary": { + "component_row_count": 7, + "formula_or_unit_mismatch_count": 7, + "within_tolerance_count": 0, + "local_formula_outlier_count": 3, + "small_delta_still_unfrozen_count": 1, + "max_delta_virupa": 83.27 + }, + "rows": [ + { + "ticket_id": "shadbala.sun.dig", + "planet": "Sun", + "canonical_component": "Digbala", + "normalized_values_virupa": { + "jyotishganit": 29.175, + "local": 24.81, + "vp_jain_local": 6.6, + "vp_jain_published": 6.59, + "xalen": 30.0 + }, + "max_delta_virupa": 23.41, + "closure_classification": "formula_or_unit_mismatch", + "mismatch_family": "multi_cluster_formula_variant", + "next_evidence_owner": "formula_source_arbitration", + "known_variants": [ + "house-cusp vs whole-house angular distance", + "rounding precision" + ], + "unit_contract": "Virupa directional-strength component; angular reference, interpolation, cap/floor and house/cusp model must be explicit.", + "source_evidence": [ + "classical directional strength rule", + "PyJHora/JHora VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Do not tune Digbala by majority vote. Resolve formula source and angular reference first." + }, + { + "ticket_id": "shadbala.moon.dig", + "planet": "Moon", + "canonical_component": "Digbala", + "normalized_values_virupa": { + "jyotishganit": 20.159, + "local": 95.48, + "vp_jain_local": 12.21, + "vp_jain_published": 12.22, + "xalen": 20.000000000000004 + }, + "max_delta_virupa": 83.27, + "closure_classification": "formula_or_unit_mismatch", + "mismatch_family": "local_formula_outlier", + "next_evidence_owner": "formula_source_arbitration", + "known_variants": [ + "house-cusp vs whole-house angular distance", + "rounding precision" + ], + "unit_contract": "Virupa directional-strength component; angular reference, interpolation, cap/floor and house/cusp model must be explicit.", + "source_evidence": [ + "classical directional strength rule", + "PyJHora/JHora VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Do not tune Digbala by majority vote. Resolve formula source and angular reference first." + }, + { + "ticket_id": "shadbala.mars.dig", + "planet": "Mars", + "canonical_component": "Digbala", + "normalized_values_virupa": { + "jyotishganit": 46.955, + "local": 77.41, + "vp_jain_local": 20.98, + "vp_jain_published": 20.99, + "xalen": 50.0 + }, + "max_delta_virupa": 56.43, + "closure_classification": "formula_or_unit_mismatch", + "mismatch_family": "local_formula_outlier", + "next_evidence_owner": "formula_source_arbitration", + "known_variants": [ + "house-cusp vs whole-house angular distance", + "rounding precision" + ], + "unit_contract": "Virupa directional-strength component; angular reference, interpolation, cap/floor and house/cusp model must be explicit.", + "source_evidence": [ + "classical directional strength rule", + "PyJHora/JHora VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Do not tune Digbala by majority vote. Resolve formula source and angular reference first." + }, + { + "ticket_id": "shadbala.mercury.dig", + "planet": "Mercury", + "canonical_component": "Digbala", + "normalized_values_virupa": { + "jyotishganit": 7.954, + "local": 12.64, + "vp_jain_local": 31.97, + "vp_jain_published": 31.97, + "xalen": 9.999999999999998 + }, + "max_delta_virupa": 24.016, + "closure_classification": "formula_or_unit_mismatch", + "mismatch_family": "multi_cluster_formula_variant", + "next_evidence_owner": "formula_source_arbitration", + "known_variants": [ + "house-cusp vs whole-house angular distance", + "rounding precision" + ], + "unit_contract": "Virupa directional-strength component; angular reference, interpolation, cap/floor and house/cusp model must be explicit.", + "source_evidence": [ + "classical directional strength rule", + "PyJHora/JHora VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Do not tune Digbala by majority vote. Resolve formula source and angular reference first." + }, + { + "ticket_id": "shadbala.jupiter.dig", + "planet": "Jupiter", + "canonical_component": "Digbala", + "normalized_values_virupa": { + "jyotishganit": 44.095, + "local": 80.59, + "vp_jain_local": 31.99, + "vp_jain_published": 31.99, + "xalen": 40.00000000000001 + }, + "max_delta_virupa": 48.6, + "closure_classification": "formula_or_unit_mismatch", + "mismatch_family": "local_formula_outlier", + "next_evidence_owner": "formula_source_arbitration", + "known_variants": [ + "house-cusp vs whole-house angular distance", + "rounding precision" + ], + "unit_contract": "Virupa directional-strength component; angular reference, interpolation, cap/floor and house/cusp model must be explicit.", + "source_evidence": [ + "classical directional strength rule", + "PyJHora/JHora VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Do not tune Digbala by majority vote. Resolve formula source and angular reference first." + }, + { + "ticket_id": "shadbala.venus.dig", + "planet": "Venus", + "canonical_component": "Digbala", + "normalized_values_virupa": { + "jyotishganit": 45.684, + "local": 69.95, + "vp_jain_local": 53.29, + "vp_jain_published": 53.29, + "xalen": 50.0 + }, + "max_delta_virupa": 24.266, + "closure_classification": "formula_or_unit_mismatch", + "mismatch_family": "multi_cluster_formula_variant", + "next_evidence_owner": "formula_source_arbitration", + "known_variants": [ + "house-cusp vs whole-house angular distance", + "rounding precision" + ], + "unit_contract": "Virupa directional-strength component; angular reference, interpolation, cap/floor and house/cusp model must be explicit.", + "source_evidence": [ + "classical directional strength rule", + "PyJHora/JHora VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Do not tune Digbala by majority vote. Resolve formula source and angular reference first." + }, + { + "ticket_id": "shadbala.saturn.dig", + "planet": "Saturn", + "canonical_component": "Digbala", + "normalized_values_virupa": { + "jyotishganit": 24.313, + "local": 19.63, + "vp_jain_local": 26.66, + "vp_jain_published": 26.67, + "xalen": 20.000000000000004 + }, + "max_delta_virupa": 7.04, + "closure_classification": "formula_or_unit_mismatch", + "mismatch_family": "small_delta_still_unfrozen", + "next_evidence_owner": "formula_source_arbitration", + "known_variants": [ + "house-cusp vs whole-house angular distance", + "rounding precision" + ], + "unit_contract": "Virupa directional-strength component; angular reference, interpolation, cap/floor and house/cusp model must be explicit.", + "source_evidence": [ + "classical directional strength rule", + "PyJHora/JHora VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Do not tune Digbala by majority vote. Resolve formula source and angular reference first." + } + ], + "boundary": "Digbala remains formula/unit mismatch. This packet narrows the next evidence owner to angular-reference formula arbitration; it does not close absolute Shadbala parity.", + "next_actions": [ + "pin Digbala formula source for each planet directional maximum", + "compare whole-house vs cusp angular distance on same chart", + "add second public numeric Shadbala worked example before tolerance freeze" + ], + "raw_hash": "83e028d352fb0e5567289cde1610e2057c54f699e283bbf0f7138801e37b10eb" +} diff --git a/references/oracle/shadbala_naisargika_closure_packet_2026_07_20.json b/references/oracle/shadbala_naisargika_closure_packet_2026_07_20.json new file mode 100644 index 00000000..cbe77a39 --- /dev/null +++ b/references/oracle/shadbala_naisargika_closure_packet_2026_07_20.json @@ -0,0 +1,182 @@ +{ + "scope": "shadbala_naisargika_closure_packet", + "created_at": "2026-07-20", + "component": "naisargika", + "canonical_component": "Naisargikabala", + "status": "same_unit_observation_frozen", + "claim_status": "partial", + "closure_classification": "within_tolerance_observation", + "absolute_parity_ready": false, + "production_tuning_allowed": false, + "truth_matrix_allowed": false, + "source_queue": "references/oracle/shadbala_component_closure_queue_v2_2026_07_19.json", + "summary": { + "component_row_count": 7, + "within_tolerance_count": 7, + "max_delta_virupa": 0.0, + "source_count_per_row": 5 + }, + "rows": [ + { + "ticket_id": "shadbala.sun.naisargika", + "planet": "Sun", + "canonical_component": "Naisargikabala", + "normalized_values_virupa": { + "jyotishganit": 60.0, + "local": 60.0, + "vp_jain_local": 60.0, + "vp_jain_published": 60.0, + "xalen": 60.0 + }, + "max_delta_virupa": 0.0, + "closure_status": "same_unit_observation_frozen", + "closure_classification": "within_tolerance_observation", + "unit_contract": "Virupa fixed natural-strength table; 60 Virupa = 1 Rupa.", + "source_evidence": [ + "classical natural strength ordering", + "VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Natural-strength table agrees across current five sources for this one VP Jain chart, but absolute Shadbala parity still requires second public case and full six-force totals." + }, + { + "ticket_id": "shadbala.moon.naisargika", + "planet": "Moon", + "canonical_component": "Naisargikabala", + "normalized_values_virupa": { + "jyotishganit": 51.43, + "local": 51.43, + "vp_jain_local": 51.43, + "vp_jain_published": 51.43, + "xalen": 51.43 + }, + "max_delta_virupa": 0.0, + "closure_status": "same_unit_observation_frozen", + "closure_classification": "within_tolerance_observation", + "unit_contract": "Virupa fixed natural-strength table; 60 Virupa = 1 Rupa.", + "source_evidence": [ + "classical natural strength ordering", + "VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Natural-strength table agrees across current five sources for this one VP Jain chart, but absolute Shadbala parity still requires second public case and full six-force totals." + }, + { + "ticket_id": "shadbala.mars.naisargika", + "planet": "Mars", + "canonical_component": "Naisargikabala", + "normalized_values_virupa": { + "jyotishganit": 17.14, + "local": 17.14, + "vp_jain_local": 17.14, + "vp_jain_published": 17.14, + "xalen": 17.14 + }, + "max_delta_virupa": 0.0, + "closure_status": "same_unit_observation_frozen", + "closure_classification": "within_tolerance_observation", + "unit_contract": "Virupa fixed natural-strength table; 60 Virupa = 1 Rupa.", + "source_evidence": [ + "classical natural strength ordering", + "VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Natural-strength table agrees across current five sources for this one VP Jain chart, but absolute Shadbala parity still requires second public case and full six-force totals." + }, + { + "ticket_id": "shadbala.mercury.naisargika", + "planet": "Mercury", + "canonical_component": "Naisargikabala", + "normalized_values_virupa": { + "jyotishganit": 25.71, + "local": 25.71, + "vp_jain_local": 25.71, + "vp_jain_published": 25.71, + "xalen": 25.71 + }, + "max_delta_virupa": 0.0, + "closure_status": "same_unit_observation_frozen", + "closure_classification": "within_tolerance_observation", + "unit_contract": "Virupa fixed natural-strength table; 60 Virupa = 1 Rupa.", + "source_evidence": [ + "classical natural strength ordering", + "VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Natural-strength table agrees across current five sources for this one VP Jain chart, but absolute Shadbala parity still requires second public case and full six-force totals." + }, + { + "ticket_id": "shadbala.jupiter.naisargika", + "planet": "Jupiter", + "canonical_component": "Naisargikabala", + "normalized_values_virupa": { + "jyotishganit": 34.29, + "local": 34.29, + "vp_jain_local": 34.29, + "vp_jain_published": 34.29, + "xalen": 34.29 + }, + "max_delta_virupa": 0.0, + "closure_status": "same_unit_observation_frozen", + "closure_classification": "within_tolerance_observation", + "unit_contract": "Virupa fixed natural-strength table; 60 Virupa = 1 Rupa.", + "source_evidence": [ + "classical natural strength ordering", + "VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Natural-strength table agrees across current five sources for this one VP Jain chart, but absolute Shadbala parity still requires second public case and full six-force totals." + }, + { + "ticket_id": "shadbala.venus.naisargika", + "planet": "Venus", + "canonical_component": "Naisargikabala", + "normalized_values_virupa": { + "jyotishganit": 42.86, + "local": 42.86, + "vp_jain_local": 42.86, + "vp_jain_published": 42.86, + "xalen": 42.86 + }, + "max_delta_virupa": 0.0, + "closure_status": "same_unit_observation_frozen", + "closure_classification": "within_tolerance_observation", + "unit_contract": "Virupa fixed natural-strength table; 60 Virupa = 1 Rupa.", + "source_evidence": [ + "classical natural strength ordering", + "VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Natural-strength table agrees across current five sources for this one VP Jain chart, but absolute Shadbala parity still requires second public case and full six-force totals." + }, + { + "ticket_id": "shadbala.saturn.naisargika", + "planet": "Saturn", + "canonical_component": "Naisargikabala", + "normalized_values_virupa": { + "jyotishganit": 8.57, + "local": 8.57, + "vp_jain_local": 8.57, + "vp_jain_published": 8.57, + "xalen": 8.57 + }, + "max_delta_virupa": 0.0, + "closure_status": "same_unit_observation_frozen", + "closure_classification": "within_tolerance_observation", + "unit_contract": "Virupa fixed natural-strength table; 60 Virupa = 1 Rupa.", + "source_evidence": [ + "classical natural strength ordering", + "VP Jain fixture", + "Xalen delta report" + ], + "claim_boundary": "Natural-strength table agrees across current five sources for this one VP Jain chart, but absolute Shadbala parity still requires second public case and full six-force totals." + } + ], + "boundary": "This packet freezes same-unit observation for Naisargikabala only. It does not close Sthana/Dig/Kala/Chesta/Drik, does not prove Shadbala total parity, and cannot tune production predictions.", + "next_actions": [ + "add a second public numeric Shadbala worked example", + "keep fixed-table source citation attached to formula_source_knowledge_base", + "continue component closure for formula_or_unit_mismatch rows" + ], + "raw_hash": "01d789215d47c293de2a0c23c0373ea9add2b1f4f41bd6e089d94d7bc254a3b7" +} diff --git a/references/oracle/three_engine_worked_example_bridge_2026_07_20.json b/references/oracle/three_engine_worked_example_bridge_2026_07_20.json new file mode 100644 index 00000000..f8b55e65 --- /dev/null +++ b/references/oracle/three_engine_worked_example_bridge_2026_07_20.json @@ -0,0 +1,92 @@ +{ + "boundary": "Bridge converts owner tracks into evidence asks; all mismatch rows remain open until replay/attribution artifacts close them.", + "claim_status": "open_queue", + "created_at": "2026-07-20", + "owner_track_links": [ + { + "categories": [ + "endpoint_or_varga_semantics" + ], + "claim_boundary": "Bridge only; does not close mismatches, tune production, or majority-vote truth.", + "closure_condition": "Close only by field-level replay against a numeric packet or by explicit method-variant attribution.", + "linked_blocking_fields": [], + "linked_candidate_count": 0, + "linked_intake_domains": [], + "next_non_numeric_evidence": [ + "identified endpoint/method contract with ayanamsa, node mode, varga, timezone semantics" + ], + "owner_track": "endpoint_contract", + "ticket_count": 10 + }, + { + "categories": [ + "shadbala_formula_variant" + ], + "claim_boundary": "Bridge only; does not close mismatches, tune production, or majority-vote truth.", + "closure_condition": "Close only by field-level replay against a numeric packet or by explicit method-variant attribution.", + "linked_blocking_fields": [ + "birth_input", + "complete birth input", + "component_name", + "expected_virupa", + "formula_variant", + "method_variant", + "planet", + "public_numeric_expected_values", + "raw_capture_hash", + "unit" + ], + "linked_candidate_count": 1, + "linked_intake_domains": [ + "shadbala_component_closure" + ], + "next_non_numeric_evidence": [], + "owner_track": "formula_source", + "ticket_count": 35 + }, + { + "categories": [ + "derived_total_from_component_variants" + ], + "claim_boundary": "Bridge only; does not close mismatches, tune production, or majority-vote truth.", + "closure_condition": "Close only by field-level replay against a numeric packet or by explicit method-variant attribution.", + "linked_blocking_fields": [], + "linked_candidate_count": 0, + "linked_intake_domains": [], + "next_non_numeric_evidence": [ + "component closure before total recomputation; explicit Rupa/Virupa total rule" + ], + "owner_track": "unit_schema", + "ticket_count": 7 + }, + { + "categories": [ + "ashtakavarga_table_or_contributor_variant" + ], + "claim_boundary": "Bridge only; does not close mismatches, tune production, or majority-vote truth.", + "closure_condition": "Close only by field-level replay against a numeric packet or by explicit method-variant attribution.", + "linked_blocking_fields": [], + "linked_candidate_count": 0, + "linked_intake_domains": [], + "next_non_numeric_evidence": [ + "public worked BAV/SAV table with contributor set, shodhana state, and Lagna inclusion" + ], + "owner_track": "worked_example", + "ticket_count": 8 + } + ], + "production_tuning_allowed": false, + "scope": "three_engine_worked_example_bridge", + "sources": { + "owner_track_batch_plan": "references/oracle/three_engine_owner_track_batch_plan_2026_07_20.json", + "worked_example_packet_intake_plan": "references/oracle/worked_example_packet_intake_plan_2026_07_20.json" + }, + "status": "bridge_ready", + "summary": { + "closed_mismatch_count": 0, + "linked_candidate_count": 1, + "linked_owner_track_count": 1, + "owner_track_count": 4 + }, + "truth_matrix_allowed": false +} diff --git a/references/oracle/worked_example_packet_intake_plan_2026_07_20.json b/references/oracle/worked_example_packet_intake_plan_2026_07_20.json new file mode 100644 index 00000000..ccd7e63e --- /dev/null +++ b/references/oracle/worked_example_packet_intake_plan_2026_07_20.json @@ -0,0 +1,204 @@ +{ + "boundary": "Intake plan only; public examples remain observation-only until numeric packets are captured and replayed.", + "claim_status": "open_queue", + "created_at": "2026-07-20", + "domain_queues": [ + { + "blocking_fields": [ + "public_numeric_expected_values", + "raw_capture_hash", + "cusp longitude", + "star_lord", + "sub_lord", + "sub_sub_lord", + "method_settings", + "birth_or_query_datetime", + "location", + "cusp_longitude", + "nakshatra_lord", + "source_table_raw", + "source_table_hash", + "license_boundary", + "table_raw_capture", + "table_hash", + "copyright_boundary", + "degree_range" + ], + "candidate_count": 2, + "closure_condition": "Promote only after exact public input, method settings, expected numeric values, raw capture, raw hash, and replay comparison are archived.", + "domain": "kp_precision_timing", + "highest_status": "runtime_only_public_oracle_missing", + "items": [ + { + "blocking_fields": [ + "public_numeric_expected_values", + "raw_capture_hash", + "cusp longitude", + "star_lord", + "sub_lord", + "sub_sub_lord", + "method_settings", + "birth_or_query_datetime", + "location", + "cusp_longitude", + "nakshatra_lord" + ], + "candidate_type": "tool_or_reference_page", + "eligibility_status": "runtime_only_public_oracle_missing", + "next_capture_artifact": "references/oracle/artifacts/kp_cusp_worked_example_packet.json", + "runtime_observation_available": true, + "topic": "KP cusp", + "upgrade_policy": "observation_only_until_numeric_packet", + "url": "https://www.astrosage.com/kp/cuspal-sub-lord.asp" + }, + { + "blocking_fields": [ + "source_table_raw", + "source_table_hash", + "license_boundary", + "public_numeric_expected_values", + "table_raw_capture", + "table_hash", + "copyright_boundary", + "degree_range", + "star_lord", + "sub_lord", + "sub_sub_lord" + ], + "candidate_type": "reference_table_candidate", + "eligibility_status": "reference_table_hash_needed", + "next_capture_artifact": "references/oracle/artifacts/kp_sub-lord_table_worked_example_packet.json", + "runtime_observation_available": false, + "topic": "KP sub-lord table", + "upgrade_policy": "observation_only_until_numeric_packet", + "url": "https://www.aryabhatt.com/astrology/krishnamurti-paddhati/kp-sub-lords-table" + } + ], + "next_action_owner": "oracle_intake" + }, + { + "blocking_fields": [ + "stable date/location/person input", + "raw_capture_hash", + "rule weights", + "public_numeric_expected_values", + "date", + "location", + "birth_moon_nakshatra", + "current_moon_nakshatra", + "tarabala_result", + "chandrabala_result", + "date/location fixture", + "sunrise/sunset source", + "sunrise", + "sunset", + "weekday", + "expected_interval" + ], + "candidate_count": 2, + "closure_condition": "Promote only after exact public input, method settings, expected numeric values, raw capture, raw hash, and replay comparison are archived.", + "domain": "muhurta_factor_scoring", + "highest_status": "raw_capture_needed", + "items": [ + { + "blocking_fields": [ + "stable date/location/person input", + "raw_capture_hash", + "rule weights", + "public_numeric_expected_values", + "date", + "location", + "birth_moon_nakshatra", + "current_moon_nakshatra", + "tarabala_result", + "chandrabala_result" + ], + "candidate_type": "worked_example_candidate", + "eligibility_status": "raw_capture_needed", + "next_capture_artifact": "references/oracle/artifacts/tarabala_chandrabala_worked_example_packet.json", + "runtime_observation_available": false, + "topic": "Tarabala/Chandrabala", + "upgrade_policy": "observation_only_until_numeric_packet", + "url": "https://www.mypanchang.com/tarabalam.php" + }, + { + "blocking_fields": [ + "date/location fixture", + "sunrise/sunset source", + "public_numeric_expected_values", + "raw_capture_hash", + "date", + "location", + "sunrise", + "sunset", + "weekday", + "expected_interval" + ], + "candidate_type": "calculation_reference_candidate", + "eligibility_status": "fixture_and_raw_capture_needed", + "next_capture_artifact": "references/oracle/artifacts/rahu_kalam_worked_example_packet.json", + "runtime_observation_available": false, + "topic": "Rahu Kalam", + "upgrade_policy": "observation_only_until_numeric_packet", + "url": "https://www.drikpanchang.com/muhurat/rahu-kalam.html" + } + ], + "next_action_owner": "oracle_intake" + }, + { + "blocking_fields": [ + "complete birth input", + "public_numeric_expected_values", + "method_variant", + "raw_capture_hash", + "birth_input", + "planet", + "component_name", + "formula_variant", + "unit", + "expected_virupa" + ], + "candidate_count": 1, + "closure_condition": "Promote only after exact public input, method settings, expected numeric values, raw capture, raw hash, and replay comparison are archived.", + "domain": "shadbala_component_closure", + "highest_status": "formula_reference_only", + "items": [ + { + "blocking_fields": [ + "complete birth input", + "public_numeric_expected_values", + "method_variant", + "raw_capture_hash", + "birth_input", + "planet", + "component_name", + "formula_variant", + "unit", + "expected_virupa" + ], + "candidate_type": "formula_reference_candidate", + "eligibility_status": "formula_reference_only", + "next_capture_artifact": "references/oracle/artifacts/shadbala_virupa_worked_example_packet.json", + "runtime_observation_available": false, + "topic": "Shadbala Virupa", + "upgrade_policy": "observation_only_until_numeric_packet", + "url": "https://www.astrojyoti.com/shadbala.htm" + } + ], + "next_action_owner": "oracle_intake" + } + ], + "production_tuning_allowed": false, + "scope": "worked_example_packet_intake_plan", + "sources": { + "eligibility": "references/oracle/worked_example_numeric_packet_eligibility_2026_07_20.json" + }, + "status": "intake_plan_ready", + "summary": { + "candidate_count": 5, + "domain_count": 3, + "domains_with_runtime_observation": 1, + "oracle_ready_count": 0 + }, + "truth_matrix_allowed": false +} diff --git a/references/real_case_calibration/real_case_website_e2e_eval_2026_07_20.json b/references/real_case_calibration/real_case_website_e2e_eval_2026_07_20.json new file mode 100644 index 00000000..f0ad85a0 --- /dev/null +++ b/references/real_case_calibration/real_case_website_e2e_eval_2026_07_20.json @@ -0,0 +1,717 @@ +{ + "acceptance_rules": [ + "Website must save/render input contract and runtime context JSON for each prompt.", + "Answers may use public cases as explanation references, not prediction proof.", + "Precise day/month claims must stay exploratory_unvalidated unless holdout closes.", + "Marriage/career/wealth/health/migration/family/education/timing/annual domains must route to matching technique context.", + "Any Shadbala/AV/KP conflict must be described as method/source difference, not majority-vote truth." + ], + "boundary": "Product E2E quality harness only; not an accuracy benchmark or independent holdout.", + "case_count": 20, + "cases": [ + { + "birth": { + "date": "1955-02-24", + "lat": 37.7749, + "lon": -122.4194, + "place": "San Francisco, CA, USA", + "source_policy": "public_record_candidate", + "time": "19:15", + "tz": -8 + }, + "case_id": "steve_jobs", + "domains": [ + "career", + "wealth", + "timing" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "财富来源和风险是什么?", + "历史关键阶段能否用 Dasha + Narayana 回看?" + ], + "subject": "Steve Jobs" + }, + { + "birth": { + "date": "1879-03-14", + "lat": 48.4011, + "lon": 9.9876, + "place": "Ulm, Germany", + "source_policy": "public_record_candidate", + "time": "11:30", + "tz": 1 + }, + "case_id": "albert_einstein", + "domains": [ + "education", + "career", + "timing" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "学习与教育路径怎么看?", + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "历史关键阶段能否用 Dasha + Narayana 回看?" + ], + "subject": "Albert Einstein" + }, + { + "birth": { + "date": "1961-08-04", + "lat": 21.3069, + "lon": -157.8583, + "place": "Honolulu, HI, USA", + "source_policy": "public_record_candidate", + "time": "19:24", + "tz": -10 + }, + "case_id": "barack_obama", + "domains": [ + "career", + "migration", + "annual" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "迁移/海外发展应看哪些宫位和 Dasha?", + "年度运势应如何避免过度承诺?" + ], + "subject": "Barack Obama" + }, + { + "birth": { + "date": "1961-07-01", + "lat": 52.8294, + "lon": 0.5143, + "place": "Sandringham, England", + "source_policy": "public_record_candidate", + "time": "19:45", + "tz": 0 + }, + "case_id": "princess_diana", + "domains": [ + "marriage", + "family", + "timing" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "婚恋关系中应看哪些印度占星指标?", + "家庭/子女主题应调用哪些分盘和宫位?", + "历史关键阶段能否用 Dasha + Narayana 回看?" + ], + "subject": "Princess Diana" + }, + { + "birth": { + "date": "1946-06-14", + "lat": 40.7282, + "lon": -73.7949, + "place": "Queens, NY, USA", + "source_policy": "public_record_candidate", + "time": "10:54", + "tz": -5 + }, + "case_id": "donald_trump", + "domains": [ + "career", + "wealth", + "annual" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "财富来源和风险是什么?", + "年度运势应如何避免过度承诺?" + ], + "subject": "Donald Trump" + }, + { + "birth": { + "date": "1954-01-29", + "lat": 33.0576, + "lon": -89.5887, + "place": "Kosciusko, MS, USA", + "source_policy": "public_record_candidate", + "time": "04:30", + "tz": -6 + }, + "case_id": "oprah_winfrey", + "domains": [ + "career", + "wealth", + "family" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "财富来源和风险是什么?", + "家庭/子女主题应调用哪些分盘和宫位?" + ], + "subject": "Oprah Winfrey" + }, + { + "birth": { + "date": "1971-06-28", + "lat": -25.7479, + "lon": 28.2293, + "place": "Pretoria, South Africa", + "source_policy": "public_record_candidate", + "time": "07:30", + "tz": 2 + }, + "case_id": "elon_musk", + "domains": [ + "career", + "migration", + "wealth" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "迁移/海外发展应看哪些宫位和 Dasha?", + "财富来源和风险是什么?" + ], + "subject": "Elon Musk" + }, + { + "birth": { + "date": "1869-10-02", + "lat": 21.6417, + "lon": 69.6293, + "place": "Porbandar, India", + "source_policy": "public_record_candidate", + "time": "07:11", + "tz": 5.5 + }, + "case_id": "mahatma_gandhi", + "domains": [ + "career", + "migration", + "timing" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "迁移/海外发展应看哪些宫位和 Dasha?", + "历史关键阶段能否用 Dasha + Narayana 回看?" + ], + "subject": "Mahatma Gandhi" + }, + { + "birth": { + "date": "1926-06-01", + "lat": 34.0522, + "lon": -118.2437, + "place": "Los Angeles, CA, USA", + "source_policy": "public_record_candidate", + "time": "09:30", + "tz": -8 + }, + "case_id": "marilyn_monroe", + "domains": [ + "marriage", + "career", + "health" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "婚恋关系中应看哪些印度占星指标?", + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "健康主题只能如何非医疗表达?" + ], + "subject": "Marilyn Monroe" + }, + { + "birth": { + "date": "1955-10-28", + "lat": 47.6062, + "lon": -122.3321, + "place": "Seattle, WA, USA", + "source_policy": "public_record_candidate", + "time": "22:00", + "tz": -8 + }, + "case_id": "bill_gates", + "domains": [ + "career", + "wealth", + "education" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "财富来源和风险是什么?", + "学习与教育路径怎么看?" + ], + "subject": "Bill Gates" + }, + { + "birth": { + "date": "1965-07-31", + "lat": 0.0, + "lon": 0.0, + "place": "Yate, England", + "source_policy": "public_record_candidate", + "time": "14:00", + "tz": 0 + }, + "case_id": "j_k_rowling", + "domains": [ + "career", + "wealth", + "timing" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "财富来源和风险是什么?", + "历史关键阶段能否用 Dasha + Narayana 回看?" + ], + "subject": "J. K. Rowling" + }, + { + "birth": { + "date": "1918-07-18", + "lat": 0.0, + "lon": 0.0, + "place": "Mvezo, South Africa", + "source_policy": "public_record_candidate", + "time": "14:54", + "tz": 0 + }, + "case_id": "nelson_mandela", + "domains": [ + "career", + "timing", + "migration" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "历史关键阶段能否用 Dasha + Narayana 回看?", + "迁移/海外发展应看哪些宫位和 Dasha?" + ], + "subject": "Nelson Mandela" + }, + { + "birth": { + "date": "1910-08-26", + "lat": 0.0, + "lon": 0.0, + "place": "Skopje, North Macedonia", + "source_policy": "public_record_candidate", + "time": "14:25", + "tz": 0 + }, + "case_id": "mother_teresa", + "domains": [ + "career", + "migration", + "health" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "迁移/海外发展应看哪些宫位和 Dasha?", + "健康主题只能如何非医疗表达?" + ], + "subject": "Mother Teresa" + }, + { + "birth": { + "date": "1958-08-29", + "lat": 0.0, + "lon": 0.0, + "place": "Gary, IN, USA", + "source_policy": "public_record_candidate", + "time": "19:33", + "tz": 0 + }, + "case_id": "michael_jackson", + "domains": [ + "career", + "wealth", + "health" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "财富来源和风险是什么?", + "健康主题只能如何非医疗表达?" + ], + "subject": "Michael Jackson" + }, + { + "birth": { + "date": "1926-04-21", + "lat": 0.0, + "lon": 0.0, + "place": "London, England", + "source_policy": "public_record_candidate", + "time": "02:40", + "tz": 0 + }, + "case_id": "queen_elizabeth_ii", + "domains": [ + "career", + "family", + "annual" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "家庭/子女主题应调用哪些分盘和宫位?", + "年度运势应如何避免过度承诺?" + ], + "subject": "Queen Elizabeth II" + }, + { + "birth": { + "date": "1917-05-29", + "lat": 0.0, + "lon": 0.0, + "place": "Brookline, MA, USA", + "source_policy": "public_record_candidate", + "time": "15:00", + "tz": 0 + }, + "case_id": "john_f_kennedy", + "domains": [ + "career", + "family", + "health" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "家庭/子女主题应调用哪些分盘和宫位?", + "健康主题只能如何非医疗表达?" + ], + "subject": "John F. Kennedy" + }, + { + "birth": { + "date": "1929-01-15", + "lat": 0.0, + "lon": 0.0, + "place": "Atlanta, GA, USA", + "source_policy": "public_record_candidate", + "time": "12:00", + "tz": 0 + }, + "case_id": "martin_luther_king_jr", + "domains": [ + "career", + "timing", + "health" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "历史关键阶段能否用 Dasha + Narayana 回看?", + "健康主题只能如何非医疗表达?" + ], + "subject": "Martin Luther King Jr." + }, + { + "birth": { + "date": "1975-06-04", + "lat": 0.0, + "lon": 0.0, + "place": "Los Angeles, CA, USA", + "source_policy": "public_record_candidate", + "time": "09:09", + "tz": 0 + }, + "case_id": "angelina_jolie", + "domains": [ + "marriage", + "family", + "career" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "婚恋关系中应看哪些印度占星指标?", + "家庭/子女主题应调用哪些分盘和宫位?", + "事业主轴是什么?", + "哪类阶段更容易爆发?" + ], + "subject": "Angelina Jolie" + }, + { + "birth": { + "date": "1963-12-18", + "lat": 0.0, + "lon": 0.0, + "place": "Shawnee, OK, USA", + "source_policy": "public_record_candidate", + "time": "06:31", + "tz": 0 + }, + "case_id": "brad_pitt", + "domains": [ + "marriage", + "career", + "wealth" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "婚恋关系中应看哪些印度占星指标?", + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "财富来源和风险是什么?" + ], + "subject": "Brad Pitt" + }, + { + "birth": { + "date": "1981-09-26", + "lat": 0.0, + "lon": 0.0, + "place": "Saginaw, MI, USA", + "source_policy": "public_record_candidate", + "time": "20:28", + "tz": 0 + }, + "case_id": "serena_williams", + "domains": [ + "career", + "health", + "annual" + ], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed" + ], + "prompts": [ + "事业主轴是什么?", + "哪类阶段更容易爆发?", + "健康主题只能如何非医疗表达?", + "年度运势应如何避免过度承诺?" + ], + "subject": "Serena Williams" + } + ], + "claim_status": "ready_contract", + "created_at": "2026-07-20", + "production_tuning_allowed": false, + "scope": "real_case_website_e2e_eval", + "truth_matrix_allowed": false +} diff --git a/scripts/answer_quality_audit.py b/scripts/answer_quality_audit.py index 97b3d5d5..e1f00c63 100644 --- a/scripts/answer_quality_audit.py +++ b/scripts/answer_quality_audit.py @@ -1,15 +1,81 @@ #!/usr/bin/env python3 +"""Audit astrology answers for commercial safety/quality boundaries.""" + from __future__ import annotations -import json, sys -FORBIDDEN = ["一定发生", "保证结婚", "保证发财", "医疗诊断", "确诊", "签证保证", "exact_day_verified", "full_year_certainty"] -REQUIRED_WHEN_TIMING = ["候选", "窗口", "边界"] -def audit_answer(text: str) -> dict: - hits = [x for x in FORBIDDEN if x.lower() in text.lower()] - timing_missing = ("什么时候" in text or "几月" in text or "哪天" in text) and not any(x in text for x in REQUIRED_WHEN_TIMING) - return {"status": "pass" if not hits and not timing_missing else "fail", "forbidden_hits": hits, "timing_boundary_missing": timing_missing} -def run(path: str | None = None) -> dict: - rows = json.load(open(path, encoding="utf-8")) if path else [] - results = [audit_answer(str(r.get("answer", r))) for r in rows] - return {"scope": "answer_quality_audit", "status": "pass" if all(r["status"] == "pass" for r in results) else "fail", "results": results} + +import argparse +import json +from pathlib import Path +from typing import Any + + +FORBIDDEN = [ + "一定发生", + "保证结婚", + "保证发财", + "医疗诊断", + "确诊", + "签证保证", + "exact_day_verified", + "full_year_certainty", +] + +TIMING_TRIGGERS = ["什么时候", "几月", "哪天", "应期", "timing", "when", "exact date"] +TIMING_BOUNDARIES = ["候选", "窗口", "边界", "exploratory_unvalidated", "未验证"] + +HEALTH_TRIGGERS = ["健康", "疾病", "医疗", "病", "health", "medical", "disease"] +HEALTH_BOUNDARIES = ["非医疗", "不能诊断", "non-medical", "建议咨询医生", "not medical"] + +CASE_TRIGGERS = ["相似案例", "真实案例", "public case", "similar case"] +CASE_BOUNDARIES = ["参考", "不是证明", "not proof", "product qa", "不能当作准确率"] + +METHOD_TRIGGERS = ["shadbala", "ashtakavarga", "kp", "流派", "方法差异"] +METHOD_BOUNDARIES = ["流派", "方法", "来源", "variant", "provenance", "不能多数投票"] + + +def _missing_boundary(text: str, triggers: list[str], boundaries: list[str]) -> bool: + low = text.lower() + return any(token.lower() in low for token in triggers) and not any(token.lower() in low for token in boundaries) + + +def audit_answer(text: str) -> dict[str, Any]: + low = text.lower() + forbidden_hits = [token for token in FORBIDDEN if token.lower() in low] + checks = { + "timing_boundary_missing": _missing_boundary(text, TIMING_TRIGGERS, TIMING_BOUNDARIES), + "health_boundary_missing": _missing_boundary(text, HEALTH_TRIGGERS, HEALTH_BOUNDARIES), + "case_boundary_missing": _missing_boundary(text, CASE_TRIGGERS, CASE_BOUNDARIES), + "method_boundary_missing": _missing_boundary(text, METHOD_TRIGGERS, METHOD_BOUNDARIES), + } + status = "pass" if not forbidden_hits and not any(checks.values()) else "fail" + return {"status": status, "forbidden_hits": forbidden_hits, **checks} + + +def _row_text(row: Any) -> str: + if isinstance(row, dict): + return str(row.get("answer") or row.get("text") or row.get("message") or row) + return str(row) + + +def run(path: str | None = None) -> dict[str, Any]: + rows = json.loads(Path(path).read_text(encoding="utf-8")) if path else [] + results = [audit_answer(_row_text(row)) for row in rows] + return { + "scope": "answer_quality_audit", + "status": "pass" if all(row["status"] == "pass" for row in results) else "fail", + "answer_count": len(results), + "results": results, + "boundary": "Text quality gate only; does not validate chart accuracy.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("path", nargs="?") + args = parser.parse_args() + print(json.dumps(run(args.path), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + if __name__ == "__main__": - print(json.dumps(run(sys.argv[1] if len(sys.argv) > 1 else None), ensure_ascii=False, indent=2)) + raise SystemExit(main()) diff --git a/scripts/capture_commercial_astrology_e2e_contexts.py b/scripts/capture_commercial_astrology_e2e_contexts.py index c8024a4a..68aba2c4 100644 --- a/scripts/capture_commercial_astrology_e2e_contexts.py +++ b/scripts/capture_commercial_astrology_e2e_contexts.py @@ -68,14 +68,79 @@ def _capture_body(question: dict[str, Any]) -> dict[str, Any]: } -def capture(contract_path: Path = DEFAULT_CONTRACT, output_dir: Path = DEFAULT_OUTPUT_DIR) -> dict[str, Any]: +def _capture_body_for_real_case(case: dict[str, Any], prompt: str) -> dict[str, Any]: + birth = case["birth"] + year, month, day = [int(part) for part in birth["date"].split("-")] + hour, minute = [int(part) for part in birth["time"].split(":")[:2]] + theme_map = { + "annual": "career", + "education": "career", + "family": "marriage", + "migration": "career", + "timing": "career", + } + themes = [theme_map.get(domain, domain) for domain in case["domains"]] + prompt_prefix = "" + if "迁移" in prompt or "海外" in prompt: + prompt_prefix = "迁移 migration foreign abroad: " + elif "家庭" in prompt or "子女" in prompt: + prompt_prefix = "家庭 family children home: " + elif "学习" in prompt or "教育" in prompt: + prompt_prefix = "教育 education study: " + elif "年度" in prompt or "运势" in prompt: + prompt_prefix = "年度 annual yearly forecast: " + elif "健康" in prompt: + prompt_prefix = "健康 health: " + elif "婚恋" in prompt or "关系" in prompt: + prompt_prefix = "婚恋 relationship marriage: " + elif "财富" in prompt or "风险" in prompt: + prompt_prefix = "财富 finance wealth: " + elif "阶段" in prompt or "何时" in prompt: + prompt_prefix = "应期 timing when: " + return { + "year": year, + "month": month, + "day": day, + "hour": hour, + "minute": minute, + "lat": birth["lat"], + "lon": birth["lon"], + "tz": birth["tz"], + "city": birth["place"], + "question": f"{prompt_prefix}{prompt}", + "question_text": f"{prompt_prefix}{prompt}", + "theme": themes, + "evaluation_domains": case["domains"], + "entry_mode": "direct_chart", + "case_id": case["case_id"], + "subject": case["subject"], + "source_policy": birth["source_policy"], + } + + +def capture( + contract_path: Path = DEFAULT_CONTRACT, + output_dir: Path = DEFAULT_OUTPUT_DIR, + max_items: int | None = None, + offset: int = 0, +) -> dict[str, Any]: contract = _load_json(contract_path) output_dir = output_dir.resolve() output_dir.mkdir(parents=True, exist_ok=True) rows: list[dict[str, Any]] = [] - for question in contract["questions"]: + if "cases" in contract: + questions = [ + {"id": f"{case['case_id']}__{index + 1}", "body": _capture_body_for_real_case(case, prompt)} + for case in contract["cases"] + for index, prompt in enumerate(case["prompts"]) + ] + else: + questions = [{"id": str(question["id"]), "body": _capture_body(question)} for question in contract["questions"]] + for question in questions[offset:]: + if max_items is not None and len(rows) >= max_items: + break qid = str(question["id"]) - result = execute_consultation_workflow(_capture_body(question), surface="commercial_e2e_capture") + result = execute_consultation_workflow(question["body"], surface="commercial_e2e_capture") context_path = output_dir / f"{qid}.json" context_path.write_text(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") rows.append( @@ -93,6 +158,7 @@ def capture(contract_path: Path = DEFAULT_CONTRACT, output_dir: Path = DEFAULT_O "contract": _display_path(contract_path), "output_dir": _display_path(output_dir), "question_count": len(rows), + "offset": offset, "rows": rows, } (output_dir / "capture_manifest.json").write_text( @@ -106,8 +172,17 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--contract", type=Path, default=DEFAULT_CONTRACT) parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR) + parser.add_argument("--max-items", type=int, default=None) + parser.add_argument("--offset", type=int, default=0) args = parser.parse_args() - print(json.dumps(capture(args.contract, args.output_dir), ensure_ascii=False, indent=2, sort_keys=True)) + print( + json.dumps( + capture(args.contract, args.output_dir, max_items=args.max_items, offset=args.offset), + ensure_ascii=False, + indent=2, + sort_keys=True, + ) + ) return 0 diff --git a/scripts/compatibility_skill_readiness_dashboard.py b/scripts/compatibility_skill_readiness_dashboard.py new file mode 100644 index 00000000..33a3f60d --- /dev/null +++ b/scripts/compatibility_skill_readiness_dashboard.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Build a bounded compatibility/synastry skill readiness dashboard. + +This is governance glue, not a truth engine. It records which relationship +layers are callable, which are registry-only, and which must stay blocked. +""" + +from __future__ import annotations + +import json +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "references" / "oracle" / "compatibility_skill_readiness_dashboard_2026_07_20.json" + + +def exists(path: str) -> bool: + return (ROOT / path).exists() + + +def layer( + layer_id: str, + name: str, + runtime_path: str | None, + runtime_status: str, + skill_status: str, + api_ui_status: str, + external_oracle_status: str, + commercial_sync_status: str, + claim_boundary: str, + evidence: list[str] | None = None, +) -> dict: + paths = list(evidence or []) + if runtime_path: + paths.insert(0, runtime_path) + return { + "layer_id": layer_id, + "name": name, + "runtime_status": runtime_status, + "runtime_path": runtime_path, + "runtime_path_exists": exists(runtime_path) if runtime_path else False, + "skill_status": skill_status, + "api_ui_status": api_ui_status, + "external_oracle_status": external_oracle_status, + "commercial_sync_status": commercial_sync_status, + "evidence_paths": paths, + "claim_boundary": claim_boundary, + } + + +def build() -> dict: + layers = [ + layer( + "ashtakoota_guna_milan", + "Ashtakoota / Guna Milan", + "scripts/ashtakoot.py", + "available", + "callable_basic", + "surface_audit_needed", + "partial", + "basic_safe_with_boundary", + "36-point compatibility can be exposed as one factor only; not deterministic marriage outcome.", + ["references/oracle/ashtakoot_oracle_cases.json", "scripts/synastry.py"], + ), + layer( + "mangal_dosha", + "Mangal / Kuja Dosha matching", + "scripts/ashtakoot.py", + "available", + "callable_basic", + "surface_audit_needed", + "partial", + "basic_safe_with_boundary", + "Use as risk flag and cancellation check; never as standalone rejection verdict.", + ), + layer( + "d9_navamsa_relationship", + "D9 Navamsa relationship layer", + "scripts/relationship_analysis.py", + "available", + "callable_context", + "surface_audit_needed", + "partial", + "safe_as_context", + "D9 can support relationship analysis; timing/outcome claims still require Dasha, transits, and external calibration.", + ["references/navamsa-marriage-deep-analysis.md"], + ), + layer( + "darakaraka", + "Darakaraka spouse significator", + "scripts/darakaraka_reader.py", + "available", + "callable_context", + "surface_audit_needed", + "source_reference_only", + "safe_as_context", + "May describe spouse/relationship themes; not a compatibility score or event proof.", + ["references/darakaraka-complete-guide.md"], + ), + layer( + "upapada_lagna", + "Upapada Lagna marriage image", + "scripts/jaimini.py", + "available_as_chart_field", + "callable_context", + "surface_audit_needed", + "source_reference_only", + "safe_as_context", + "UL is a relationship image layer; must not replace full chart, D9, Dasha, or event evidence.", + ["references/data-bridge-mapping.md", "references/jaimini-complete-system.md"], + ), + layer( + "relationship_combinations", + "Relationship rule-family combinations", + None, + "registry_only", + "contract_only", + "no_runtime_surface", + "missing", + "research_only", + "Indexed rule families still need source packets, deduplication, tests, and claim gates before runtime use.", + ["references/oracle/relationship_combinations_rule_family_registry_2026_07_19.json"], + ), + layer( + "relationship_ashtakavarga_overlay", + "Relationship Ashtakavarga overlay", + None, + "missing_runtime", + "not_invoked", + "no_runtime_surface", + "missing", + "blocked_until_oracle", + "Do not expose relationship AV overlay until rules, examples, and field-level oracle packets exist.", + ["references/oracle/ashtakavarga_advanced_usage_gap_registry_2026_07_19.json"], + ), + layer( + "planet_lagna_kuta", + "Planet/Lagna Kuta variants", + None, + "registry_only", + "not_invoked", + "no_runtime_surface", + "missing", + "blocked_until_oracle", + "Do not claim full top-tier compatibility until Planet/Lagna Kuta variants and worked examples are validated.", + ["references/oracle/compatibility_full_system_gap_registry_2026_07_19.json"], + ), + layer( + "western_composite_davidson_boundary", + "Western composite / Davidson boundary", + None, + "out_of_scope", + "not_invoked", + "no_vedic_surface", + "not_applicable_vedic_core", + "out_of_scope_for_vedic_core", + "Keep out of Vedic commercial runtime unless explicitly scoped as cross-system research.", + ), + ] + return { + "scope": "compatibility_skill_readiness_dashboard", + "created_at": "2026-07-20", + "status": "dashboard_v1", + "claim_status": "partial", + "production_tuning_allowed": False, + "truth_matrix_allowed": False, + "summary": { + "layer_count": len(layers), + "runtime_available_count": sum(1 for x in layers if x["runtime_status"] in {"available", "available_as_chart_field"}), + "blocked_or_registry_only_count": sum(1 for x in layers if x["commercial_sync_status"] in {"research_only", "blocked_until_oracle", "out_of_scope_for_vedic_core"}), + "oracle_ready_count": 0, + }, + "skill_use_policy": { + "allowed": "Expose basic compatibility factors and relationship context with explicit low/partial evidence boundaries.", + "forbidden": "Do not present any layer as deterministic marriage success, divorce prediction, exact relationship timing, or complete synastry truth.", + }, + "layers": layers, + } + + +def main() -> None: + OUT.write_text(json.dumps(build(), ensure_ascii=False, indent=2) + "\n") + print(OUT) + + +if __name__ == "__main__": + main() diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 3ba24d7f..021f38e4 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -1510,6 +1510,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): def _require_dynamic_rectification_token(self): configured = os.environ.get('JYOTISH_DYNAMIC_RECTIFICATION_TOKEN', '').strip() + if not configured: + service_role = os.environ.get('SUPABASE_SERVICE_ROLE_KEY', '').strip() + if service_role: + configured = hashlib.sha256( + f'jyotisha-dynamic-rectification-v1:{service_role}'.encode('utf-8') + ).hexdigest() supplied = self._job_access_token() matches = secrets.compare_digest(supplied, configured) if not configured or not matches: diff --git a/scripts/muhurta_numeric_candidate_capture_packet.py b/scripts/muhurta_numeric_candidate_capture_packet.py new file mode 100644 index 00000000..ce74fbc8 --- /dev/null +++ b/scripts/muhurta_numeric_candidate_capture_packet.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Create CI-safe capture packets for Muhurta numeric source candidates.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +TRIAGE = ROOT / "references/oracle/public_worked_example_source_triage_2026_07_20.json" +OUT = ROOT / "references/oracle/muhurta_numeric_candidate_capture_packet_2026_07_20.json" + + +def sha(obj: Any) -> str: + return hashlib.sha256(json.dumps(obj, ensure_ascii=False, sort_keys=True).encode("utf-8")).hexdigest() + + +def next_path(source_id: str) -> str: + return f"references/oracle/artifacts/{source_id}_raw_capture_packet.json" + + +def build(date: str) -> dict[str, Any]: + triage = json.loads(TRIAGE.read_text(encoding="utf-8")) + rows = [] + for src in triage["sources"]: + if src["domain"] != "muhurta_factor_scoring" or not src["numeric_fields_present"]: + continue + request = { + "source_id": src["source_id"], + "url": src["url"], + "topic": src["topic"], + "observed_numeric_fields": src["observed_numeric_fields"], + } + missing = list(src["missing_for_oracle"]) + for field in ["raw_capture_hash", "exact_method_settings", "replay_comparison"]: + if field not in missing: + missing.append(field) + rows.append( + { + "source_id": src["source_id"], + "domain": src["domain"], + "topic": src["topic"], + "url": src["url"], + "source_observation_hash": src["observation_hash"], + "canonical_request_hash": sha(request), + "observed_numeric_fields": src["observed_numeric_fields"], + "raw_capture_status": "pending_raw_page_capture", + "upgrade_status": "not_oracle_ready", + "missing_for_oracle": missing, + "next_artifact_path": next_path(src["source_id"]), + "claim_boundary": "Numeric-looking public source; not oracle-ready until raw page, exact settings, hash, and local replay comparison are archived.", + } + ) + return { + "scope": "muhurta_numeric_candidate_capture_packet", + "created_at": date, + "status": "capture_packet_ready", + "claim_status": "source_intake_only", + "production_tuning_allowed": False, + "truth_matrix_allowed": False, + "sources": {"source_triage": str(TRIAGE.relative_to(ROOT))}, + "summary": { + "candidate_count": len(rows), + "oracle_ready_count": 0, + "pending_raw_capture_count": sum(1 for row in rows if row["raw_capture_status"] == "pending_raw_page_capture"), + }, + "capture_rows": rows, + "boundary": "Capture packet staging only; does not calculate or validate Muhurta verdicts.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--date", default="2026-07-20") + args = parser.parse_args() + print(json.dumps(build(args.date), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/prashna_marga_excerpt_locator.py b/scripts/prashna_marga_excerpt_locator.py new file mode 100644 index 00000000..f6655538 --- /dev/null +++ b/scripts/prashna_marga_excerpt_locator.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 +"""Locate short Prasna Marga Sphuta source windows by IA text URLs.""" +from __future__ import annotations +import argparse, hashlib, json +from pathlib import Path +from urllib.request import Request, urlopen + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "references/oracle/prashna_marga_raw_capture_packet_2026_07_20.json" +OUT = ROOT / "references/oracle/prashna_marga_excerpt_locator_2026_07_20.json" +TERMS = ["Trisphuta", "Chatusphuta", "Catusphuta", "Panchasphuta", "Gulika"] + +def sha(s: str) -> str: return hashlib.sha256(s.encode('utf-8','ignore')).hexdigest() +def fetch(url: str) -> str: + req=Request(url,headers={'User-Agent':'Mozilla/5.0'}) + with urlopen(req,timeout=25) as r: return r.read().decode('utf-8','ignore') + +def locate(text: str, source_id: str, file_name: str, url: str): + lines=text.splitlines() + for i,line in enumerate(lines): + if any(t.lower() in line.lower() for t in TERMS): + start=max(0,i-2); end=min(len(lines),i+3) + window='\n'.join(lines[start:end]) + compact=' '.join(window.split())[:240] + return {'source_id':source_id,'file_name':file_name,'download_url':url,'matched_line':i+1,'line_start':start+1,'line_end':end,'matched_terms':[t for t in TERMS if t.lower() in window.lower()],'window_hash':sha(window),'short_context':compact} + return None + +def build(date: str): + src=json.load(open(SRC)); wins=[]; blocked=[] + for item in src['internet_archive_items']: + for f in item['files']: + if f['format']!='DjVuTXT': continue + try: + hit=locate(fetch(f['download_url']), item['identifier'], f['name'], f['download_url']) + if hit: wins.append(hit) + else: blocked.append({'source_id':item['identifier'],'file_name':f['name'],'status':'locator_terms_not_found'}) + except Exception as e: + blocked.append({'source_id':item['identifier'],'file_name':f['name'],'status':'fetch_failed','error':type(e).__name__}) + return {'scope':'prashna_marga_excerpt_locator','created_at':date,'status':'excerpt_locator_ready','claim_status':'source_intake_only','production_tuning_allowed':False,'truth_matrix_allowed':False,'summary':{'located_window_count':len(wins),'blocked_file_count':len(blocked),'oracle_ready_count':0},'located_windows':wins,'blocked_files':blocked,'missing_for_oracle':['complete_prashna_input','raw excerpt capture','line-level transcription review','legal external replay'],'upgrade_status':'candidate_not_oracle','next_steps':['raw excerpt capture with page/line coordinates and independent transcription review','compare VedAstro vs B.V. Raman wording before formula tuning'],'boundary':'Short context and hashes only; no long copyrighted text is reproduced and no truth upgrade is allowed.'} + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) +if __name__=='__main__': main() diff --git a/scripts/prashna_marga_raw_capture_packet.py b/scripts/prashna_marga_raw_capture_packet.py new file mode 100644 index 00000000..ca662eeb --- /dev/null +++ b/scripts/prashna_marga_raw_capture_packet.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +"""Pin Internet Archive Prasna Marga source metadata for later raw excerpt capture.""" +from __future__ import annotations +import argparse, hashlib, json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "references/oracle/prashna_marga_raw_capture_packet_2026_07_20.json" + +ITEMS = [ + { + 'identifier':'PrasnaMargaBVR','title':'Prasna Marga - Dr. BV Raman','metadata_url':'https://archive.org/metadata/PrasnaMargaBVR', + 'files':[{'name':'Prasna Marga 1_djvu.txt','format':'DjVuTXT','sha1':'bed3491a79ca5039409dac7fd62e386f7de55a47','download_url':'https://archive.org/download/PrasnaMargaBVR/Prasna%20Marga%201_djvu.txt'}, {'name':'Prasna Marga 1.pdf','format':'Text PDF','sha1':'86839fc2d13509309ec3a14a160c861bb223d844','download_url':'https://archive.org/download/PrasnaMargaBVR/Prasna%20Marga%201.pdf'}] + }, + { + 'identifier':'prasna-marga-part-2-by-bv-raman','title':'Prasna Marga Part 2 By BV Raman','metadata_url':'https://archive.org/metadata/prasna-marga-part-2-by-bv-raman', + 'files':[{'name':'Prasna Marga Part 2 by BV Raman_djvu.txt','format':'DjVuTXT','sha1':'17c432465520ef75bde7a2c4fa167ff119664666','download_url':'https://archive.org/download/prasna-marga-part-2-by-bv-raman/Prasna%20Marga%20Part%202%20by%20BV%20Raman_djvu.txt'}] + }, +] + +def digest(obj): return hashlib.sha256(json.dumps(obj,ensure_ascii=False,sort_keys=True).encode()).hexdigest() + +def build(date): + items=[] + for it in ITEMS: + row={**it,'source_metadata_hash':'','upgrade_status':'candidate_not_oracle','claim_boundary':'Metadata/file hash pin only; no book text is vendored and no numeric truth is upgraded.'} + row['source_metadata_hash']=digest(row) + items.append(row) + return {'scope':'prashna_marga_raw_capture_packet','created_at':date,'status':'raw_capture_metadata_ready','claim_status':'source_intake_only','production_tuning_allowed':False,'truth_matrix_allowed':False,'summary':{'ia_item_count':len(items),'pinned_file_count':sum(len(i['files']) for i in items),'oracle_ready_count':0},'field_locator_terms':['Trisphuta','Chatusphuta','Catusphuta','Panchasphuta','Gulika'],'internet_archive_items':items,'next_steps':['raw excerpt capture around locator terms with page/line coordinates','compare B.V. Raman scan vs VedAstro transcription','only then classify formula_variant vs source_transcription'],'boundary':'Capture metadata only; long copyrighted text is not reproduced.'} + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) +if __name__=='__main__': main() diff --git a/scripts/prashna_oracle_queue.py b/scripts/prashna_oracle_queue.py new file mode 100644 index 00000000..bed9023c --- /dev/null +++ b/scripts/prashna_oracle_queue.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Create Prashna input contract and numeric oracle candidate queue.""" +from __future__ import annotations +import argparse, hashlib, json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "references/oracle/prashna_input_contract_2026_07_20.json" +QUEUE = ROOT / "references/oracle/prashna_numeric_oracle_packet_queue_2026_07_20.json" + +def h(obj): + return hashlib.sha256(json.dumps(obj, ensure_ascii=False, sort_keys=True).encode()).hexdigest() + +def build(date: str): + contract = { + "scope": "prashna_input_contract", + "created_at": date, + "status": "contract_ready", + "claim_status": "ready_contract", + "production_tuning_allowed": False, + "truth_matrix_allowed": False, + "required_fields": [ + {"field": "question_datetime_local", "format": "YYYY-MM-DDTHH:MM:SS", "boundary": "exact time question is received/accepted"}, + {"field": "location", "format": "lat/lon + place label", "boundary": "place of querent/astrologer must be explicit"}, + {"field": "timezone", "format": "IANA or UTC offset", "boundary": "no implicit local machine timezone"}, + {"field": "ayanamsa", "format": "named sidereal ayanamsa", "boundary": "default must be recorded, e.g. Lahiri"}, + {"field": "node_mode", "format": "mean|true", "boundary": "Rahu/Ketu mode must be frozen"}, + ], + "optional_fields": ["question_text", "querent_id", "house_focus", "language"], + "claim_boundary": "Input contract only; does not validate Prashna predictions or external numeric parity.", + } + rows = [ + { + "source_id": "vedastro_prasna_marga_ch5_sphuta_example", + "domain": "horary_annual_sensitive_points", + "technique_family": "sphuta_trisphuta_family", + "url": "https://vedastro.org/book/PrasnaMarga/Chapter5", + "source_role": "public_numeric_candidate", + "numeric_fields_present": True, + "expected_values": { + "sun": "4s 3° 8' 25\"", + "moon": "3s 19° 36' 34\"", + "lagna": "3s 27° 22'", + "gulika": "3s 14° 10'", + "rahu": "3s 8° 16'", + "trisphuta": "11s 1° 8' 34\"", + "chatusphuta": "2s 15° 18' 34\"", + "panchasphuta": "5s 23° 34' 34\"", + }, + "missing_for_oracle": ["complete_prashna_input", "ayanamsa", "node_mode", "timezone", "raw_capture_hash", "local_replay", "pyjhora_or_other_legal_replay"], + "upgrade_status": "candidate_not_oracle", + "candidate_hash": "", + "claim_boundary": "Numeric Sphuta example exists, but full Prashna input/settings are incomplete; use as candidate only.", + } + ] + for row in rows: + row["candidate_hash"] = h(row) + queue = { + "scope": "prashna_numeric_oracle_packet_queue", + "created_at": date, + "status": "queue_ready", + "claim_status": "open_queue", + "production_tuning_allowed": False, + "truth_matrix_allowed": False, + "summary": {"candidate_count": len(rows), "numeric_candidate_count": sum(r["numeric_fields_present"] for r in rows), "oracle_ready_count": 0}, + "rows": rows, + "boundary": "Queue only; no Prashna/Saham/Gulika/Sphuta claim is upgraded until complete input, raw/hash and local/external replay close.", + } + return {"contract": contract, "queue": queue} + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument("--date", default="2026-07-20"); args=ap.parse_args() + data=build(args.date) + CONTRACT.write_text(json.dumps(data["contract"], ensure_ascii=False, indent=2, sort_keys=True)+"\n") + QUEUE.write_text(json.dumps(data["queue"], ensure_ascii=False, indent=2, sort_keys=True)+"\n") + print(json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True)) +if __name__ == "__main__": main() diff --git a/scripts/prashna_sphuta_closure_dashboard.py b/scripts/prashna_sphuta_closure_dashboard.py new file mode 100644 index 00000000..7a22a7e2 --- /dev/null +++ b/scripts/prashna_sphuta_closure_dashboard.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Summarize Prashna/Sphuta closure chain and remaining gates.""" +from __future__ import annotations +import argparse, json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "references/oracle/prashna_sphuta_closure_dashboard_2026_07_20.json" +CHAIN = [ + 'prashna_input_contract_2026_07_20.json','prashna_numeric_oracle_packet_queue_2026_07_20.json','prashna_sphuta_candidate_replay_readiness_2026_07_20.json','prashna_sphuta_mismatch_arbitration_2026_07_20.json','prashna_marga_raw_capture_packet_2026_07_20.json','prashna_marga_excerpt_locator_2026_07_20.json','prashna_sphuta_source_comparison_matrix_2026_07_20.json','prashna_sphuta_line_review_queue_2026_07_20.json','prashna_sphuta_review_result_template_2026_07_20.json','prashna_sphuta_review_result_validation_2026_07_20.json'] + +def build(date): + packets=[] + for f in CHAIN: + p=ROOT/'references/oracle'/f + d=json.load(open(p)); packets.append({'path':str(p.relative_to(ROOT)),'scope':d.get('scope'),'claim_status':d.get('claim_status'),'status':d.get('status')}) + gates=[ + {'gate_id':'human_line_review','status':'blocked','evidence':'review_result_validation valid_completed_review_count == 0'}, + {'gate_id':'complete_prashna_input','status':'blocked','evidence':'candidate lacks question datetime/location/timezone/ayanamsa/node'}, + {'gate_id':'legal_external_replay','status':'blocked','evidence':'no PyJHora/other legal replay packet yet'}, + {'gate_id':'formula_or_transcription_arbitration','status':'open_queue','evidence':'Trisphuta matches; Chatusphuta/Panchasphuta mismatch'}, + ] + return {'scope':'prashna_sphuta_closure_dashboard','created_at':date,'status':'closure_dashboard_ready','claim_status':'blocked_until_human_labels','production_tuning_allowed':False,'truth_matrix_allowed':False,'commercial_sync_status':'research_observation_only','summary':{'packet_chain_count':len(packets),'blocked_gate_count':sum(g['status']=='blocked' for g in gates),'truth_upgrade_count':0},'packet_chain':packets,'gates':gates,'forbidden_uses':['do_not_use_for_deterministic_prashna_verdict','do_not_tune_formula_from_candidate_mismatch','do_not_claim_external_oracle_ready'],'next_actions':['fill review_result_template via human/second-source review','capture complete Prashna input if a worked example is found','run legal external replay only after inputs close'],'boundary':'Dashboard only; summarizes blocked gates and does not upgrade any Prashna/Sphuta claim.'} + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) +if __name__=='__main__': main() diff --git a/scripts/prashna_sphuta_line_review_queue.py b/scripts/prashna_sphuta_line_review_queue.py new file mode 100644 index 00000000..1124b9d0 --- /dev/null +++ b/scripts/prashna_sphuta_line_review_queue.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +"""Create line-level review tasks from Prasna Marga excerpt locator windows.""" +from __future__ import annotations +import argparse, json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LOC = ROOT / "references/oracle/prashna_marga_excerpt_locator_2026_07_20.json" +OUT = ROOT / "references/oracle/prashna_sphuta_line_review_queue_2026_07_20.json" + +def build(date): + loc=json.load(open(LOC)); tasks=[] + for i,w in enumerate(loc['located_windows'],1): + tasks.append({'task_id':f'PSLRQ-{i:03d}','source_id':w['source_id'],'file_name':w['file_name'],'download_url':w['download_url'],'line_start':w['line_start'],'line_end':w['line_end'],'window_hash':w['window_hash'],'short_context':w['short_context'],'fields_to_check':['trisphuta','chatusphuta','catusphuta','panchasphuta','gulika'],'review_status':'needs_human_or_second_source_review','candidate_causes':['formula_variant','source_transcription','naming_variant'],'claim_boundary':'Review task only; no formula change or truth upgrade.'}) + return {'scope':'prashna_sphuta_line_review_queue','created_at':date,'status':'review_queue_ready','claim_status':'open_queue','production_tuning_allowed':False,'truth_matrix_allowed':False,'summary':{'review_task_count':len(tasks),'truth_upgrade_count':0},'acceptance_criteria':['do_not_copy_long_text','record_line_coordinates','classify_formula_variant_or_transcription','preserve_window_hash','require_second_source_or_scan_review'],'review_tasks':tasks,'boundary':'Human/second-source transcription queue only.'} + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) +if __name__=='__main__': main() diff --git a/scripts/prashna_sphuta_oss_case_probe.py b/scripts/prashna_sphuta_oss_case_probe.py new file mode 100644 index 00000000..02d36288 --- /dev/null +++ b/scripts/prashna_sphuta_oss_case_probe.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python3 +"""Run installed PyJHora Sphuta OSS case as isolated observation.""" +from __future__ import annotations +import argparse, contextlib, hashlib, importlib.metadata as md, io, json +from pathlib import Path + +ROOT=Path(__file__).resolve().parents[1] +OUT=ROOT/'references/oracle/prashna_sphuta_oss_case_probe_2026_07_20.json' + +def h(o): return hashlib.sha256(json.dumps(o,ensure_ascii=False,sort_keys=True,default=str).encode()).hexdigest() + +def build(date): + rows=[]; meta={} + try: + captured_stdout = io.StringIO() + captured_stderr = io.StringIO() + with contextlib.redirect_stdout(captured_stdout), contextlib.redirect_stderr(captured_stderr): + import jhora + from jhora.panchanga import drik + from jhora.horoscope.chart import sphuta + try: dist=md.metadata('jhora') + except md.PackageNotFoundError: dist=md.metadata('PyJHora') + meta={'package':dist.get('Name'),'version':dist.get('Version'),'license':dist.get('License') or 'AGPL detected from package metadata text','module_file':jhora.__file__,'captured_import_stdout_hash':h(captured_stdout.getvalue()),'captured_import_stderr_hash':h(captured_stderr.getvalue())} + dob=drik.Date(1996,12,7); tob=(10,34,0); place=drik.Place('Chennai',13.0878,80.2785,5.5) + for field,fn,expected in [('tri_sphuta',sphuta.tri_sphuta,'Pisces 20° 47’ 20"'),('chatur_sphuta',sphuta.chatur_sphuta,'Scorpio 12° 21’ 15"'),('pancha_sphuta',sphuta.pancha_sphuta,'Aries 22° 54’ 29"')]: + try: + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + got=fn(dob,tob,place,divisional_chart_factor=1) + rows.append({'field':field,'status':'observed','raw_result':got,'expected_from_oss_case':expected}) + except Exception as e: + rows.append({'field':field,'status':'runtime_error','error':type(e).__name__,'expected_from_oss_case':expected}) + except Exception as e: + meta={'package':'jhora','import_error':type(e).__name__} + for field in ['tri_sphuta','chatur_sphuta','pancha_sphuta']: rows.append({'field':field,'status':'runtime_error','error':'jhora_import_failed'}) + data={'scope':'prashna_sphuta_oss_case_probe','created_at':date,'status':'oss_probe_ready','claim_status':'tooling_observation_only','production_tuning_allowed':False,'truth_matrix_allowed':False,'oracle_ready':False,'license_boundary':'agpl_observation_only_do_not_vendor','case':{'source':'jhora.tests.pvr_tests.sphuta_tests','dob':'1996-12-07','tob':'10:34:00','place':'Chennai 13.0878,80.2785 +05:30'},'package_metadata':meta,'rows':rows,'boundary':'Runs installed OSS package only; no AGPL implementation is copied and no oracle truth is upgraded.'} + data['raw_hash']=h({'meta':meta,'rows':rows}) + return data + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) +if __name__=='__main__': main() diff --git a/scripts/prashna_sphuta_review_result_template.py b/scripts/prashna_sphuta_review_result_template.py new file mode 100644 index 00000000..617001cd --- /dev/null +++ b/scripts/prashna_sphuta_review_result_template.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Create blank human review result templates for Prashna Sphuta line tasks.""" +from __future__ import annotations +import argparse, json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +QUEUE = ROOT / "references/oracle/prashna_sphuta_line_review_queue_2026_07_20.json" +OUT = ROOT / "references/oracle/prashna_sphuta_review_result_template_2026_07_20.json" +ALLOWED = ["formula_variant", "source_transcription", "naming_variant", "insufficient_evidence"] +REQ = ["reviewer_id", "reviewed_at", "source_line_coordinates", "second_source_or_scan_evidence", "review_notes"] + +def build(date): + q=json.load(open(QUEUE)); templates=[] + for t in q['review_tasks']: + templates.append({**t,'review_result':None,'allowed_results':ALLOWED,'required_human_fields':REQ,'completed':False,'upgrade_after_completion':'requires_replay_packet_and_gate_review'}) + return {'scope':'prashna_sphuta_review_result_template','created_at':date,'status':'blank_review_template_ready','claim_status':'blocked_until_human_labels','production_tuning_allowed':False,'truth_matrix_allowed':False,'summary':{'template_count':len(templates),'completed_review_count':0,'truth_upgrade_count':0},'upgrade_policy':'no_upgrade_until_completed_review_and_replay','templates':templates,'boundary':'Blank template only; human review fields must be filled before any classification or formula change.'} + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) +if __name__=='__main__': main() diff --git a/scripts/prashna_sphuta_review_result_validator.py b/scripts/prashna_sphuta_review_result_validator.py new file mode 100644 index 00000000..9596c5ca --- /dev/null +++ b/scripts/prashna_sphuta_review_result_validator.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Validate Prashna Sphuta human review result templates.""" +from __future__ import annotations +import argparse, json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +TEMPLATE = ROOT / "references/oracle/prashna_sphuta_review_result_template_2026_07_20.json" +OUT = ROOT / "references/oracle/prashna_sphuta_review_result_validation_2026_07_20.json" + +def build(date): + src=json.load(open(TEMPLATE)); rows=[] + for t in src['templates']: + missing=[] + if t.get('review_result') is None: missing.append('review_result') + for f in t['required_human_fields']: + if not t.get(f): missing.append(f) + valid=not missing and t.get('review_result') in t['allowed_results'] + rows.append({'task_id':t['task_id'],'validation_status':'valid_completed_review' if valid else 'blocked_missing_human_review','missing_fields':missing,'review_result':t.get('review_result'),'replay_gate_ready':False,'claim_boundary':'Validation only; replay gate also requires complete Prashna input.'}) + return {'scope':'prashna_sphuta_review_result_validation','created_at':date,'status':'validation_ready','claim_status':'blocked_until_human_labels','production_tuning_allowed':False,'truth_matrix_allowed':False,'allowed_results':src['templates'][0]['allowed_results'],'replay_gate_policy':'requires_valid_completed_review_and_complete_prashna_input','summary':{'template_count':len(rows),'valid_completed_review_count':sum(r['validation_status']=='valid_completed_review' for r in rows),'replay_gate_ready_count':0},'validation_rows':rows,'boundary':'Blank templates remain blocked; completed review alone still cannot upgrade truth without replay.'} + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) +if __name__=='__main__': main() diff --git a/scripts/prashna_sphuta_source_comparison_matrix.py b/scripts/prashna_sphuta_source_comparison_matrix.py new file mode 100644 index 00000000..28661084 --- /dev/null +++ b/scripts/prashna_sphuta_source_comparison_matrix.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Build field-level comparison matrix for Prashna Sphuta sources.""" +from __future__ import annotations +import argparse, json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REPLAY = ROOT / "references/oracle/prashna_sphuta_candidate_replay_readiness_2026_07_20.json" +LOCATOR = ROOT / "references/oracle/prashna_marga_excerpt_locator_2026_07_20.json" +OUT = ROOT / "references/oracle/prashna_sphuta_source_comparison_matrix_2026_07_20.json" + +FIELDS=['sun','moon','lagna','gulika','rahu','trisphuta','chatusphuta','panchasphuta'] + +def status(row, field): + if field in ['sun','moon','lagna','gulika','rahu']: return 'input_value_only' + got=row['computed_from_expected_degrees'][field]; exp=row['expected_degrees'][field] + return 'match' if abs(((got-exp+180)%360)-180) <= .02 else 'mismatch' + +def build(date): + replay=json.load(open(REPLAY)); locator=json.load(open(LOCATOR)); base=replay['rows'][0] + has_ia=locator['summary']['located_window_count']>0 + rows=[] + for f in FIELDS: + rows.append({'field':f,'vedastro_expected_degree':base['expected_degrees'][f],'local_formula_degree':base['computed_from_expected_degrees'].get(f),'local_vs_vedastro_status':status(base,f),'ia_excerpt_status':'located_context' if has_ia and f in ['gulika','trisphuta','chatusphuta','panchasphuta'] else 'not_field_specific','claim_boundary':'Field comparison only; no Prashna truth upgrade without complete input/raw/replay.'}) + return {'scope':'prashna_sphuta_source_comparison_matrix','created_at':date,'status':'comparison_matrix_ready','claim_status':'open_queue','production_tuning_allowed':False,'truth_matrix_allowed':False,'summary':{'field_count':len(rows),'match_count':sum(r['local_vs_vedastro_status']=='match' for r in rows),'mismatch_count':sum(r['local_vs_vedastro_status']=='mismatch' for r in rows),'truth_upgrade_count':0},'ia_excerpt_window_count':locator['summary']['located_window_count'],'field_rows':rows,'next_evidence':['line-level transcription review','complete Prashna input','legal external replay'],'boundary':'Matrix connects VedAstro expected values, local arithmetic replay, and IA locator hashes; still open queue.'} + +def main(): + ap=argparse.ArgumentParser(); ap.add_argument('--date',default='2026-07-20'); args=ap.parse_args(); data=build(args.date); OUT.write_text(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)+'\n'); print(json.dumps(data,ensure_ascii=False,indent=2,sort_keys=True)) +if __name__=='__main__': main() diff --git a/scripts/production_smoke.py b/scripts/production_smoke.py index 2442fa75..17deea6e 100644 --- a/scripts/production_smoke.py +++ b/scripts/production_smoke.py @@ -22,7 +22,7 @@ def fetch(url: str, timeout: float) -> tuple[int, str, float]: raise RuntimeError(str(error.reason)) from error -def check(base_url: str, timeout: float) -> dict: +def check(base_url: str, timeout: float, expected_git_sha: str | None = None) -> dict: base = base_url.rstrip("/") checks: list[dict] = [] @@ -41,11 +41,16 @@ def check(base_url: str, timeout: float) -> dict: checks.append( { "name": "health", - "ok": status in {200, 503} and health.get("status") in {"ok", "degraded", "blocked"}, + "ok": ( + status in {200, 503} + and health.get("status") in {"ok", "degraded", "blocked"} + and (not expected_git_sha or health.get("deployment", {}).get("gitCommit") == expected_git_sha) + ), "status": status, "latency_ms": round(elapsed * 1000), "health_status": health.get("status"), "checks": sorted((health.get("checks") or {}).keys()), + "deployment_git_commit": health.get("deployment", {}).get("gitCommit"), } ) @@ -60,9 +65,10 @@ def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--base-url", default="https://jyotisha.chat") parser.add_argument("--timeout", type=float, default=8.0) + parser.add_argument("--expected-git-sha") args = parser.parse_args() try: - report = check(args.base_url, args.timeout) + report = check(args.base_url, args.timeout, args.expected_git_sha) except Exception as error: # noqa: BLE001 - CLI smoke should report compact failure. report = {"base_url": args.base_url, "ok": False, "error": str(error)} print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) diff --git a/scripts/public_worked_example_source_triage.py b/scripts/public_worked_example_source_triage.py new file mode 100644 index 00000000..890b98dc --- /dev/null +++ b/scripts/public_worked_example_source_triage.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +"""Triage public worked-example sources before numeric oracle capture.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / "references/oracle/public_worked_example_source_triage_2026_07_20.json" + + +def obs_hash(row: dict[str, Any]) -> str: + payload = json.dumps({k: row[k] for k in sorted(row) if k != "observation_hash"}, sort_keys=True, ensure_ascii=False) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def source(**row: Any) -> dict[str, Any]: + base = { + "numeric_fields_present": False, + "upgrade_status": "candidate_not_oracle", + "missing_for_oracle": [], + "claim_boundary": "Source triage only; do not upgrade until raw page capture, exact settings, expected numeric values, hash, and replay comparison are archived.", + } + base.update(row) + base["observation_hash"] = obs_hash(base) + return base + + +def build(date: str) -> dict[str, Any]: + rows = [ + source( + source_id="mypanchang_edison_2025_panchangam", + domain="muhurta_factor_scoring", + topic="Tarabala/Chandrabala/Panchangam daily factors", + url="https://www.mypanchang.com/phppanchang.php?cityhead=&cityname=Edison-NJ&mn=04&monthtype=1&yr=2025", + source_role="numeric_candidate", + numeric_fields_present=True, + observed_numeric_fields=["tarabalam periods", "chandrabalam periods", "nakshatra", "rasi", "tithi", "yoga", "karana"], + missing_for_oracle=["raw_capture_hash", "exact_date_selection", "timezone", "sunrise", "formula_weight_contract"], + ), + source( + source_id="drikpanchang_mumbai_rahu_2026_07_20", + domain="muhurta_factor_scoring", + topic="Rahu Kalam daily interval", + url="https://www.drikpanchang.com/muhurat/rahu-kalam.html?date=20/07/2026&geoname-id=1275339", + source_role="numeric_candidate", + numeric_fields_present=True, + observed_numeric_fields=["rahu kalam interval", "weekday", "city/date scoped daily table"], + missing_for_oracle=["raw_capture_hash", "sunrise", "sunset", "timezone", "calculation_rule_replay"], + ), + source( + source_id="mypanchang_tarabalam_chakra", + domain="muhurta_factor_scoring", + topic="Tarabalam/Chandrabalam formula reference", + url="https://www.mypanchang.com/tarabalam.php", + source_role="formula_reference", + numeric_fields_present=False, + observed_numeric_fields=[], + missing_for_oracle=["birth_moon_nakshatra", "current_moon_nakshatra", "worked_numeric_example", "raw_capture_hash"], + ), + source( + source_id="astrosage_kp_cuspal_sub_lord", + domain="kp_precision_timing", + topic="KP cuspal sub lord calculator/reference", + url="https://www.astrosage.com/kp/cuspal-sub-lord.asp", + source_role="runtime_or_reference_candidate", + numeric_fields_present=False, + observed_numeric_fields=[], + missing_for_oracle=["public_birth_or_query_input", "cusp_longitude", "star_lord", "sub_lord", "sub_sub_lord", "raw_capture_hash"], + ), + source( + source_id="astrojyoti_shadbala_formula", + domain="shadbala_component_closure", + topic="Shadbala formula reference", + url="https://www.astrojyoti.com/shadbala.htm", + source_role="formula_reference", + numeric_fields_present=False, + observed_numeric_fields=[], + missing_for_oracle=["complete_birth_input", "component_virupa_table", "method_variant", "raw_capture_hash"], + ), + ] + return { + "scope": "public_worked_example_source_triage", + "created_at": date, + "status": "source_triage_ready", + "claim_status": "source_intake_only", + "production_tuning_allowed": False, + "truth_matrix_allowed": False, + "summary": { + "source_count": len(rows), + "numeric_candidate_count": sum(1 for row in rows if row["numeric_fields_present"]), + "formula_reference_count": sum(1 for row in rows if row["source_role"] == "formula_reference"), + "oracle_ready_count": 0, + }, + "sources": rows, + "boundary": "Public source triage only. Numeric candidates require raw capture and replay before becoming oracle packets.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--date", default="2026-07-20") + args = parser.parse_args() + print(json.dumps(build(args.date), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/real_case_website_e2e_eval.py b/scripts/real_case_website_e2e_eval.py new file mode 100644 index 00000000..d6f61d12 --- /dev/null +++ b/scripts/real_case_website_e2e_eval.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +"""Build a public-real-case website E2E evaluation contract.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +CASES = [ + ("steve_jobs", "Steve Jobs", "1955-02-24", "19:15", "San Francisco, CA, USA", ["career", "wealth", "timing"]), + ("albert_einstein", "Albert Einstein", "1879-03-14", "11:30", "Ulm, Germany", ["education", "career", "timing"]), + ("barack_obama", "Barack Obama", "1961-08-04", "19:24", "Honolulu, HI, USA", ["career", "migration", "annual"]), + ("princess_diana", "Princess Diana", "1961-07-01", "19:45", "Sandringham, England", ["marriage", "family", "timing"]), + ("donald_trump", "Donald Trump", "1946-06-14", "10:54", "Queens, NY, USA", ["career", "wealth", "annual"]), + ("oprah_winfrey", "Oprah Winfrey", "1954-01-29", "04:30", "Kosciusko, MS, USA", ["career", "wealth", "family"]), + ("elon_musk", "Elon Musk", "1971-06-28", "07:30", "Pretoria, South Africa", ["career", "migration", "wealth"]), + ("mahatma_gandhi", "Mahatma Gandhi", "1869-10-02", "07:11", "Porbandar, India", ["career", "migration", "timing"]), + ("marilyn_monroe", "Marilyn Monroe", "1926-06-01", "09:30", "Los Angeles, CA, USA", ["marriage", "career", "health"]), + ("bill_gates", "Bill Gates", "1955-10-28", "22:00", "Seattle, WA, USA", ["career", "wealth", "education"]), + ("j_k_rowling", "J. K. Rowling", "1965-07-31", "14:00", "Yate, England", ["career", "wealth", "timing"]), + ("nelson_mandela", "Nelson Mandela", "1918-07-18", "14:54", "Mvezo, South Africa", ["career", "timing", "migration"]), + ("mother_teresa", "Mother Teresa", "1910-08-26", "14:25", "Skopje, North Macedonia", ["career", "migration", "health"]), + ("michael_jackson", "Michael Jackson", "1958-08-29", "19:33", "Gary, IN, USA", ["career", "wealth", "health"]), + ("queen_elizabeth_ii", "Queen Elizabeth II", "1926-04-21", "02:40", "London, England", ["career", "family", "annual"]), + ("john_f_kennedy", "John F. Kennedy", "1917-05-29", "15:00", "Brookline, MA, USA", ["career", "family", "health"]), + ("martin_luther_king_jr", "Martin Luther King Jr.", "1929-01-15", "12:00", "Atlanta, GA, USA", ["career", "timing", "health"]), + ("angelina_jolie", "Angelina Jolie", "1975-06-04", "09:09", "Los Angeles, CA, USA", ["marriage", "family", "career"]), + ("brad_pitt", "Brad Pitt", "1963-12-18", "06:31", "Shawnee, OK, USA", ["marriage", "career", "wealth"]), + ("serena_williams", "Serena Williams", "1981-09-26", "20:28", "Saginaw, MI, USA", ["career", "health", "annual"]), +] + + +QUESTION_MATRIX = { + "career": ["事业主轴是什么?", "哪类阶段更容易爆发?"], + "wealth": ["财富来源和风险是什么?"], + "marriage": ["婚恋关系中应看哪些印度占星指标?"], + "health": ["健康主题只能如何非医疗表达?"], + "migration": ["迁移/海外发展应看哪些宫位和 Dasha?"], + "family": ["家庭/子女主题应调用哪些分盘和宫位?"], + "education": ["学习与教育路径怎么看?"], + "timing": ["历史关键阶段能否用 Dasha + Narayana 回看?"], + "annual": ["年度运势应如何避免过度承诺?"], +} + + +def build(date: str) -> dict[str, Any]: + cases = [] + for case_id, subject, date_s, time_s, place, domains in CASES: + cases.append( + { + "case_id": case_id, + "subject": subject, + "birth": {"date": date_s, "time": time_s, "place": place, "source_policy": "public_record_candidate"}, + "domains": domains, + "prompts": [q for d in domains for q in QUESTION_MATRIX[d]], + "expected_runtime_context": [ + "birth_input_contract", + "ayanamsa_node_mode", + "D1", + "D9_or_relevant_varga", + "Dasha", + "Narayana_Dasha_for_timing", + "functional_benefic_malefic", + "claim_boundary", + "similar_case_reference_allowed", + ], + } + ) + return { + "scope": "real_case_website_e2e_eval", + "created_at": date, + "claim_status": "ready_contract", + "production_tuning_allowed": False, + "truth_matrix_allowed": False, + "case_count": len(cases), + "cases": cases, + "acceptance_rules": [ + "Website must save/render input contract and runtime context JSON for each prompt.", + "Answers may use public cases as explanation references, not prediction proof.", + "Precise day/month claims must stay exploratory_unvalidated unless holdout closes.", + "Marriage/career/wealth/health/migration/family/education/timing/annual domains must route to matching technique context.", + "Any Shadbala/AV/KP conflict must be described as method/source difference, not majority-vote truth.", + ], + "boundary": "Product E2E quality harness only; not an accuracy benchmark or independent holdout.", + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--date", default="2026-07-20") + args = parser.parse_args() + print(json.dumps(build(args.date), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/three_engine_worked_example_bridge.py b/scripts/three_engine_worked_example_bridge.py new file mode 100644 index 00000000..9f64c3e8 --- /dev/null +++ b/scripts/three_engine_worked_example_bridge.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Link three-engine mismatch owner tracks to worked-example intake domains.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +PLAN = ROOT / "references/oracle/three_engine_owner_track_batch_plan_2026_07_20.json" +INTAKE = ROOT / "references/oracle/worked_example_packet_intake_plan_2026_07_20.json" + +TRACK_TO_INTAKE = { + "formula_source": ["shadbala_component_closure"], + "derived_total": ["shadbala_component_closure"], +} + + +def build(date: str) -> dict[str, Any]: + plan = json.loads(PLAN.read_text(encoding="utf-8")) + intake = json.loads(INTAKE.read_text(encoding="utf-8")) + intake_by_domain = {row["domain"]: row for row in intake["domain_queues"]} + links = [] + for batch in plan["batches"]: + domains = TRACK_TO_INTAKE.get(batch["owner_track"], []) + linked = [intake_by_domain[d] for d in domains if d in intake_by_domain] + links.append( + { + "owner_track": batch["owner_track"], + "ticket_count": batch["ticket_count"], + "categories": batch["categories"], + "linked_intake_domains": domains, + "linked_candidate_count": sum(row["candidate_count"] for row in linked), + "linked_blocking_fields": sorted({field for row in linked for field in row["blocking_fields"]}), + "next_non_numeric_evidence": batch["next_evidence"] if not domains else [], + "closure_condition": "Close only by field-level replay against a numeric packet or by explicit method-variant attribution.", + "claim_boundary": "Bridge only; does not close mismatches, tune production, or majority-vote truth.", + } + ) + return { + "scope": "three_engine_worked_example_bridge", + "created_at": date, + "status": "bridge_ready", + "claim_status": "open_queue", + "production_tuning_allowed": False, + "truth_matrix_allowed": False, + "sources": { + "owner_track_batch_plan": str(PLAN.relative_to(ROOT)), + "worked_example_packet_intake_plan": str(INTAKE.relative_to(ROOT)), + }, + "summary": { + "owner_track_count": len(links), + "linked_owner_track_count": sum(1 for row in links if row["linked_intake_domains"]), + "linked_candidate_count": sum(row["linked_candidate_count"] for row in links), + "closed_mismatch_count": 0, + }, + "owner_track_links": links, + "boundary": "Bridge converts owner tracks into evidence asks; all mismatch rows remain open until replay/attribution artifacts close them.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--date", default="2026-07-20") + args = parser.parse_args() + print(json.dumps(build(args.date), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/unified_consultation_orchestrator.py b/scripts/unified_consultation_orchestrator.py index e462cb6f..2289397b 100644 --- a/scripts/unified_consultation_orchestrator.py +++ b/scripts/unified_consultation_orchestrator.py @@ -87,6 +87,14 @@ class UnifiedConsultationOrchestrator: "wealth": "wealth", "career": "career", "health": "health", + "migration": "migration", + "foreign": "migration", + "education": "education", + "study": "education", + "family": "family", + "children": "family", + "annual": "annual", + "yearly": "annual", "spirituality": "spirituality", "事业": "career", "婚恋": "marriage", @@ -95,10 +103,28 @@ class UnifiedConsultationOrchestrator: "财富": "wealth", "财运": "wealth", "健康": "health", + "迁移": "migration", + "海外": "migration", + "教育": "education", + "学习": "education", + "家庭": "family", + "子女": "family", + "年度": "annual", + "流年": "annual", "灵性": "spirituality", } _DEFAULT_THEMES = ["career", "marriage", "wealth"] - _ALLOWED_THEMES = {"career", "marriage", "wealth", "health", "spirituality"} + _ALLOWED_THEMES = { + "annual", + "career", + "education", + "family", + "health", + "marriage", + "migration", + "spirituality", + "wealth", + } _ROUTE_DEFINITIONS = { "career": RouteDefinition( question_type="career", @@ -118,6 +144,36 @@ class UnifiedConsultationOrchestrator: focus_techniques=["D2", "D11", "Dasha", "Shadbala", "Ashtakavarga"], display_label="finance", ), + "health": RouteDefinition( + question_type="health", + primary_theme="health", + focus_techniques=["D1", "D6", "D8", "Dasha", "Shadbala", "non-medical boundary"], + display_label="health", + ), + "migration": RouteDefinition( + question_type="migration", + primary_theme="migration", + focus_techniques=["D4", "D12", "12th house", "Dasha", "Narayana Dasha"], + display_label="migration", + ), + "family": RouteDefinition( + question_type="family", + primary_theme="family", + focus_techniques=["D7", "D12", "4th house", "5th house", "9th house", "Dasha"], + display_label="family", + ), + "education": RouteDefinition( + question_type="education", + primary_theme="education", + focus_techniques=["D5", "D24", "5th house", "9th house", "Dasha"], + display_label="education", + ), + "annual": RouteDefinition( + question_type="annual", + primary_theme="annual", + focus_techniques=["Annual chart boundary", "Dasha", "Transit", "Tajika candidate", "claim boundary"], + display_label="annual", + ), "timing": RouteDefinition( question_type="timing", primary_theme="career", @@ -135,6 +191,11 @@ class UnifiedConsultationOrchestrator: "career": ["compute_chart", "run_rectification_gate", "run_thematic_report"], "relationship": ["compute_chart", "run_rectification_gate", "run_thematic_report"], "finance": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "health": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "migration": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "family": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "education": ["compute_chart", "run_rectification_gate", "run_thematic_report"], + "annual": ["compute_chart", "run_rectification_gate", "run_muhurta_panchanga", "run_thematic_report"], "timing": ["compute_chart", "run_rectification_gate", "run_muhurta_panchanga", "run_thematic_report"], "general": ["compute_chart", "run_rectification_gate", "run_thematic_report"], } @@ -172,6 +233,11 @@ class UnifiedConsultationOrchestrator: "career": ("career", "job", "work", "promotion", "business", "profession", "事业", "工作", "升职", "生意"), "relationship": ("marriage", "married", "wedding", "relationship", "love", "spouse", "partner", "divorce", "婚恋", "婚姻", "感情", "配偶", "恋爱", "结婚", "marry"), "finance": ("money", "wealth", "finance", "investment", "property", "income", "财务", "财富", "投资", "房产", "收入"), + "health": ("health", "illness", "medical", "disease", "vitality", "健康", "疾病", "病", "体力", "医疗"), + "migration": ("migration", "foreign", "abroad", "overseas", "relocation", "迁移", "海外", "出国", "搬迁", "远方"), + "family": ("family", "children", "home", "mother", "father", "家庭", "子女", "孩子", "父母", "家宅"), + "education": ("education", "study", "learning", "school", "degree", "学习", "教育", "学历", "学校", "考试"), + "annual": ("annual", "yearly", "this year", "next year", "年度", "流年", "今年", "明年", "年运"), } first_hits: list[tuple[int, str]] = [] for route_name, tokens in domain_tokens.items(): @@ -195,6 +261,16 @@ class UnifiedConsultationOrchestrator: route = self._ROUTE_DEFINITIONS["relationship"] elif "wealth" in normalized_themes: route = self._ROUTE_DEFINITIONS["finance"] + elif "health" in normalized_themes: + route = self._ROUTE_DEFINITIONS["health"] + elif "migration" in normalized_themes: + route = self._ROUTE_DEFINITIONS["migration"] + elif "family" in normalized_themes: + route = self._ROUTE_DEFINITIONS["family"] + elif "education" in normalized_themes: + route = self._ROUTE_DEFINITIONS["education"] + elif "annual" in normalized_themes: + route = self._ROUTE_DEFINITIONS["annual"] else: route = self._ROUTE_DEFINITIONS["general"] diff --git a/scripts/worked_example_packet_intake_plan.py b/scripts/worked_example_packet_intake_plan.py new file mode 100644 index 00000000..a4076908 --- /dev/null +++ b/scripts/worked_example_packet_intake_plan.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Build a domain intake plan from numeric worked-example eligibility rows.""" + +from __future__ import annotations + +import argparse +import json +from collections import defaultdict +from pathlib import Path +from typing import Any + + +ROOT = Path(__file__).resolve().parents[1] +ELIGIBILITY = ROOT / "references/oracle/worked_example_numeric_packet_eligibility_2026_07_20.json" +OUT = ROOT / "references/oracle/worked_example_packet_intake_plan_2026_07_20.json" + +DOMAIN_BY_TOPIC = { + "KP cusp": "kp_precision_timing", + "KP sub-lord table": "kp_precision_timing", + "Tarabala/Chandrabala": "muhurta_factor_scoring", + "Rahu Kalam": "muhurta_factor_scoring", + "Shadbala Virupa": "shadbala_component_closure", +} + +STATUS_RANK = { + "runtime_only_public_oracle_missing": 4, + "reference_table_hash_needed": 3, + "raw_capture_needed": 2, + "fixture_and_raw_capture_needed": 2, + "formula_reference_only": 1, +} + +CANONICAL_BLOCKERS = { + "fixed public input case": "public_numeric_expected_values", + "per-component Virupa expected values": "public_numeric_expected_values", + "expected factor table": "public_numeric_expected_values", + "expected interval": "public_numeric_expected_values", + "worked example link": "public_numeric_expected_values", + "raw output hash": "raw_capture_hash", + "captured raw hash": "raw_capture_hash", + "raw capture hash": "raw_capture_hash", + "table hash": "source_table_hash", + "captured table raw": "source_table_raw", + "license/copyright boundary": "license_boundary", + "formula variant": "method_variant", + "method settings": "method_settings", + "ayanamsa": "method_settings", + "house_system": "method_settings", +} + + +def canonicalize(fields: list[str]) -> list[str]: + out = [] + for field in fields: + key = CANONICAL_BLOCKERS.get(field, field) + if key not in out: + out.append(key) + return out + + +def topic_domain(topic: str) -> str: + return DOMAIN_BY_TOPIC.get(topic, "worked_example_collection") + + +def build(date: str) -> dict[str, Any]: + source = json.loads(ELIGIBILITY.read_text(encoding="utf-8")) + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in source["rows"]: + item = { + "topic": row["topic"], + "url": row["url"], + "candidate_type": row["candidate_type"], + "eligibility_status": row["eligibility_status"], + "runtime_observation_available": row["runtime_observation_available"], + "blocking_fields": canonicalize(row["missing_for_oracle"]), + "upgrade_policy": "observation_only_until_numeric_packet", + "next_capture_artifact": f"references/oracle/artifacts/{row['topic'].lower().replace('/', '_').replace(' ', '_')}_worked_example_packet.json", + } + grouped[topic_domain(row["topic"])].append(item) + + domain_queues = [] + for domain in sorted(grouped): + items = grouped[domain] + statuses = [item["eligibility_status"] for item in items] + blockers = [] + for item in items: + for field in item["blocking_fields"]: + if field not in blockers: + blockers.append(field) + domain_queues.append( + { + "domain": domain, + "candidate_count": len(items), + "highest_status": max(statuses, key=lambda x: STATUS_RANK.get(x, 0)), + "blocking_fields": blockers, + "next_action_owner": "oracle_intake", + "closure_condition": "Promote only after exact public input, method settings, expected numeric values, raw capture, raw hash, and replay comparison are archived.", + "items": items, + } + ) + + return { + "scope": "worked_example_packet_intake_plan", + "created_at": date, + "status": "intake_plan_ready", + "claim_status": "open_queue", + "production_tuning_allowed": False, + "truth_matrix_allowed": False, + "sources": {"eligibility": str(ELIGIBILITY.relative_to(ROOT))}, + "summary": { + "domain_count": len(domain_queues), + "candidate_count": sum(row["candidate_count"] for row in domain_queues), + "oracle_ready_count": 0, + "domains_with_runtime_observation": sum( + 1 for row in domain_queues if any(item["runtime_observation_available"] for item in row["items"]) + ), + }, + "domain_queues": domain_queues, + "boundary": "Intake plan only; public examples remain observation-only until numeric packets are captured and replayed.", + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--date", default="2026-07-20") + args = parser.parse_args() + print(json.dumps(build(args.date), ensure_ascii=False, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_answer_quality_audit.py b/tests/test_answer_quality_audit.py new file mode 100644 index 00000000..176a7c4a --- /dev/null +++ b/tests/test_answer_quality_audit.py @@ -0,0 +1,35 @@ +import json +import subprocess +from pathlib import Path + +from scripts.answer_quality_audit import audit_answer + + +ROOT = Path(__file__).resolve().parents[1] + + +def test_answer_quality_audit_blocks_absolute_claims() -> None: + result = audit_answer("你一定发生婚姻,并且保证发财。") + assert result["status"] == "fail" + assert "一定发生" in result["forbidden_hits"] + + +def test_answer_quality_audit_requires_timing_health_case_and_method_boundaries() -> None: + assert audit_answer("什么时候结婚?今年几月。")["timing_boundary_missing"] is True + assert audit_answer("健康看这里。")["health_boundary_missing"] is True + assert audit_answer("相似案例说明这个预测正确。")["case_boundary_missing"] is True + assert audit_answer("Shadbala 和 Ashtakavarga 结果不同。")["method_boundary_missing"] is True + + +def test_answer_quality_audit_passes_when_boundaries_are_present(tmp_path: Path) -> None: + rows = [ + { + "answer": "应期只能给候选窗口,claim_status=exploratory_unvalidated;健康为非医疗表达;相似案例只是参考,不是证明;Shadbala 是流派/方法差异。" + } + ] + path = tmp_path / "answers.json" + path.write_text(json.dumps(rows, ensure_ascii=False), encoding="utf-8") + data = json.loads(subprocess.check_output(["python3", "scripts/answer_quality_audit.py", str(path)], cwd=ROOT, text=True)) + assert data["scope"] == "answer_quality_audit" + assert data["status"] == "pass" + assert data["answer_count"] == 1 diff --git a/tests/test_capture_commercial_astrology_e2e_contexts.py b/tests/test_capture_commercial_astrology_e2e_contexts.py index 1bd60362..efb143dc 100644 --- a/tests/test_capture_commercial_astrology_e2e_contexts.py +++ b/tests/test_capture_commercial_astrology_e2e_contexts.py @@ -24,3 +24,15 @@ def test_capture_writes_runtime_contexts_without_required_layer_echo(tmp_path: P assert data["success"] is True assert "consumer_context" in data assert "required_layers" not in data + + +def test_capture_supports_public_real_case_website_e2e_contract(tmp_path: Path) -> None: + contract = ROOT / "references" / "real_case_calibration" / "real_case_website_e2e_eval_2026_07_20.json" + manifest = capture_script.capture(contract_path=contract, output_dir=tmp_path, max_items=3) + assert manifest["question_count"] == 3 + first = manifest["rows"][0] + assert "__" in first["id"] + context_path = ROOT / first["context_file"] if not Path(first["context_file"]).is_absolute() else Path(first["context_file"]) + data = json.loads(context_path.read_text(encoding="utf-8")) + assert data["success"] is True + assert "consumer_context" in data diff --git a/tests/test_compatibility_skill_readiness_dashboard.py b/tests/test_compatibility_skill_readiness_dashboard.py new file mode 100644 index 00000000..e19fe959 --- /dev/null +++ b/tests/test_compatibility_skill_readiness_dashboard.py @@ -0,0 +1,57 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references" / "oracle" / "compatibility_skill_readiness_dashboard_2026_07_20.json" +INDEX = ROOT / "references" / "oracle" / "evidence_packet_index_2026_07_19.json" + + +def test_compatibility_skill_dashboard_generator_creates_bounded_layers(): + subprocess.run( + ["python3", "scripts/compatibility_skill_readiness_dashboard.py"], + cwd=ROOT, + check=True, + ) + + data = json.loads(ARTIFACT.read_text()) + assert data["scope"] == "compatibility_skill_readiness_dashboard" + assert data["claim_status"] == "partial" + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + + layers = {layer["layer_id"]: layer for layer in data["layers"]} + for layer_id in [ + "ashtakoota_guna_milan", + "mangal_dosha", + "d9_navamsa_relationship", + "darakaraka", + "upapada_lagna", + "relationship_combinations", + "relationship_ashtakavarga_overlay", + "planet_lagna_kuta", + "western_composite_davidson_boundary", + ]: + assert layer_id in layers + + assert layers["ashtakoota_guna_milan"]["runtime_status"] == "available" + assert layers["ashtakoota_guna_milan"]["external_oracle_status"] == "partial" + assert layers["upapada_lagna"]["runtime_path"] == "scripts/jaimini.py" + assert layers["upapada_lagna"]["runtime_path_exists"] is True + assert layers["relationship_ashtakavarga_overlay"]["runtime_status"] in { + "missing_runtime", + "registry_only", + } + assert layers["western_composite_davidson_boundary"]["commercial_sync_status"] == "out_of_scope_for_vedic_core" + assert all("claim_boundary" in layer and layer["claim_boundary"] for layer in data["layers"]) + + +def test_compatibility_dashboard_is_indexed_as_partial_contract_only_packet(): + index = json.loads(INDEX.read_text()) + packets = {packet["packet_id"]: packet for packet in index["packets"]} + packet = packets["compatibility_skill_readiness_dashboard"] + assert packet["path"] == "references/oracle/compatibility_skill_readiness_dashboard_2026_07_20.json" + assert packet["domain"] == "compatibility" + assert packet["claim_status"] == "partial" + assert packet["consumer_policy"] == "research_to_commercial_contract_only" diff --git a/tests/test_fragment_migration_registries.py b/tests/test_fragment_migration_registries.py new file mode 100644 index 00000000..f0d4ff94 --- /dev/null +++ b/tests/test_fragment_migration_registries.py @@ -0,0 +1,46 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EVENT = ROOT / "references/oracle/event_judgment_fragment_rule_family_registry_2026_07_21.json" +JOURNEY = ROOT / "references/oracle/research_birth_time_journey_ui_contract_2026_07_21.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_event_judgment_fragment_registry_ports_rules_not_engine(): + data = json.loads(EVENT.read_text(encoding="utf-8")) + assert data["source_policy"] == "rule_family_inventory_only_no_runtime_copy" + assert data["claim_status"] == "ready_contract" + routes = {row["route"]: row for row in data["route_families"]} + assert {"relationship", "career", "wealth"} <= set(routes) + relationship = {row["key"] for row in routes["relationship"]["required_families"]} + assert {"d9_navamsa", "upapada_lagna", "vimshottari_current", "narayana_current"} <= relationship + career = {row["key"] for row in routes["career"]["required_families"]} + assert {"d10_dasamsa", "a10_karma_pada", "shadbala"} <= career + wealth = {row["key"] for row in routes["wealth"]["required_families"]} + assert {"d2_hora", "ashtakavarga_house_scores", "gains_convergence"} <= wealth + assert any("Functional Benefic/Malefic" in item for item in data["global_requirements"]) + + +def test_birth_time_journey_contract_ports_behavior_not_commercial_runtime(): + data = json.loads(JOURNEY.read_text(encoding="utf-8")) + assert data["source_policy"] == "behavior_contract_only_no_commercial_code" + contracts = {row["contract_id"]: row for row in data["contracts"]} + assert { + "profile_mode_hides_chat_overlay", + "new_chat_restores_chat_surface", + "candidate_claim_stays_exploratory", + "local_resume_only", + "user_error_contract", + } <= set(contracts) + assert "Supabase runtime" in data["forbidden_imports"] + assert "credits/payment/subscription logic" in data["forbidden_imports"] + + +def test_fragment_migration_registries_are_indexed(): + packets = { + row["packet_id"]: row + for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"] + } + assert packets["event_judgment_fragment_rule_family_registry_2026_07_21"]["claim_status"] == "ready_contract" + assert packets["research_birth_time_journey_ui_contract_2026_07_21"]["claim_status"] == "ready_contract" diff --git a/tests/test_fragment_second_pass_candidate_ledger.py b/tests/test_fragment_second_pass_candidate_ledger.py new file mode 100644 index 00000000..e56f6ef3 --- /dev/null +++ b/tests/test_fragment_second_pass_candidate_ledger.py @@ -0,0 +1,46 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +LEDGER = ROOT / "references/oracle/fragment_second_pass_candidate_ledger_2026_07_21.json" + + +def test_second_pass_fragment_ledger_has_only_allowed_decisions(): + data = json.loads(LEDGER.read_text(encoding="utf-8")) + assert data["scope"] == "fragment_second_pass_candidate_ledger" + assert data["claim_status"] == "open_queue" + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + allowed = set(data["allowed_decisions"]) + assert allowed == { + "migrate_to_research_test_or_registry", + "reference_only", + "forbidden_private_or_obsolete", + } + assert len(data["candidates"]) == 5 + assert all(row["decision"] in allowed for row in data["candidates"]) + + +def test_private_handan_packets_are_forbidden_not_migrated(): + rows = { + row["candidate_id"]: row + for row in json.loads(LEDGER.read_text(encoding="utf-8"))["candidates"] + } + for candidate_id in [ + "workbuddy_shadbala_handan_operator_card", + "workbuddy_first_shadbala_packet_assistant", + ]: + assert rows[candidate_id]["decision"] == "forbidden_private_or_obsolete" + assert "Do not migrate" in rows[candidate_id]["migration_plan"] + + +def test_reusable_fragments_become_tests_or_registries_not_runtime_copy(): + rows = { + row["candidate_id"]: row + for row in json.loads(LEDGER.read_text(encoding="utf-8"))["candidates"] + } + assert rows["workbuddy_event_judgment_engine"]["decision"] == "migrate_to_research_test_or_registry" + assert "Do not copy old engine" in rows["workbuddy_event_judgment_engine"]["migration_plan"] + assert rows["commercial_birth_time_journey_tests"]["decision"] == "migrate_to_research_test_or_registry" + assert "Copy no code" in rows["commercial_birth_time_journey_tests"]["risk"] + assert rows["vedicastro_kp_source_table_candidate"]["decision"] == "reference_only" diff --git a/tests/test_kp_oracle_queue_and_hash.py b/tests/test_kp_oracle_queue_and_hash.py index e38b456a..ad4e1106 100644 --- a/tests/test_kp_oracle_queue_and_hash.py +++ b/tests/test_kp_oracle_queue_and_hash.py @@ -8,6 +8,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] HASH_SCRIPT = ROOT / "scripts/kp_external_table_hash_manifest.py" +HASH_ARTIFACT = ROOT / "references/oracle/kp_external_table_hash_manifest_2026_07_20.json" QUEUE = ROOT / "references/oracle/kp_cusp_worked_example_oracle_queue_2026_07_19.json" INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" @@ -49,4 +50,19 @@ def test_kp_hash_and_oracle_queue_are_indexed() -> None: data = json.loads(INDEX.read_text(encoding="utf-8")) packets = {packet["packet_id"]: packet for packet in data["packets"]} assert packets["kp_external_table_hash_manifest"]["path"] == "scripts/kp_external_table_hash_manifest.py" + assert packets["kp_external_table_hash_manifest_2026_07_20"]["path"] == "references/oracle/kp_external_table_hash_manifest_2026_07_20.json" assert packets["kp_cusp_worked_example_oracle_queue"]["claim_status"] == "blocked" + + +def test_kp_external_table_hash_artifact_records_current_fixture_blocker() -> None: + data = json.loads(HASH_ARTIFACT.read_text(encoding="utf-8")) + assert data["scope"] == "kp_external_table_hash_manifest" + assert data["table_id"] == "VedicAstro_KP_SL_Divisions" + assert data["status"] in {"fixed_hash", "fixture_missing"} + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + if data["status"] == "fixture_missing": + assert data["claim_status"] == "blocked_fixture_missing" + assert data["next_action"] == "pin legal source table file or keep KP exact cusp oracle blocked" + else: + assert len(data["sha256"]) == 64 diff --git a/tests/test_muhurta_numeric_candidate_capture_packet.py b/tests/test_muhurta_numeric_candidate_capture_packet.py new file mode 100644 index 00000000..470400de --- /dev/null +++ b/tests/test_muhurta_numeric_candidate_capture_packet.py @@ -0,0 +1,44 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references" / "oracle" / "muhurta_numeric_candidate_capture_packet_2026_07_20.json" +INDEX = ROOT / "references" / "oracle" / "evidence_packet_index_2026_07_19.json" + + +def test_muhurta_numeric_candidate_capture_packet_selects_only_numeric_muhurta_sources(): + data = json.loads( + subprocess.check_output( + ["python3", "scripts/muhurta_numeric_candidate_capture_packet.py", "--date", "2026-07-20"], + cwd=ROOT, + text=True, + ) + ) + assert data["scope"] == "muhurta_numeric_candidate_capture_packet" + assert data["claim_status"] == "source_intake_only" + assert data["summary"]["candidate_count"] == 2 + assert data["summary"]["oracle_ready_count"] == 0 + assert data["production_tuning_allowed"] is False + ids = {row["source_id"] for row in data["capture_rows"]} + assert ids == {"mypanchang_edison_2025_panchangam", "drikpanchang_mumbai_rahu_2026_07_20"} + + +def test_muhurta_numeric_candidate_capture_packet_has_hashes_and_replay_blockers(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + for row in data["capture_rows"]: + assert row["source_observation_hash"] + assert row["canonical_request_hash"] + assert row["raw_capture_status"] == "pending_raw_page_capture" + assert row["upgrade_status"] == "not_oracle_ready" + assert "raw_capture_hash" in row["missing_for_oracle"] + assert "replay_comparison" in row["missing_for_oracle"] + assert row["next_artifact_path"].startswith("references/oracle/artifacts/") + + +def test_muhurta_numeric_candidate_capture_packet_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + packet = packets["muhurta_numeric_candidate_capture_packet_2026_07_20"] + assert packet["domain"] == "muhurta_factor_scoring" + assert packet["claim_status"] == "source_intake_only" diff --git a/tests/test_oss_worked_example_source_matrix.py b/tests/test_oss_worked_example_source_matrix.py new file mode 100644 index 00000000..a1e2663b --- /dev/null +++ b/tests/test_oss_worked_example_source_matrix.py @@ -0,0 +1,42 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MATRIX = ROOT / "references/oracle/oss_worked_example_source_matrix_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_oss_worked_example_source_matrix_tracks_reusable_sources_and_boundaries(): + data = json.loads(MATRIX.read_text(encoding="utf-8")) + assert data["scope"] == "oss_worked_example_source_matrix" + assert data["claim_status"] == "source_intake_only" + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + + rows = {row["source_id"]: row for row in data["sources"]} + for source_id in [ + "pyjhora_pvr_tests", + "jyotishganit_github", + "vedicastro_kp_runtime", + "fusionstrings_panchangam", + "bidyashish_panchang", + "kp_sub_lord_boundary_tables", + ]: + assert source_id in rows + assert rows[source_id]["license_status"] + assert rows[source_id]["case_usefulness"] + assert rows[source_id]["promotion_boundary"] + + assert rows["pyjhora_pvr_tests"]["reuse_policy"] == "black_box_observation_only" + assert rows["vedicastro_kp_runtime"]["candidate_domains"] == ["kp_precision_timing"] + assert rows["fusionstrings_panchangam"]["license_status"] == "permissive_candidate_verify_repo_license" + assert rows["kp_sub_lord_boundary_tables"]["numeric_packet_status"] == "candidate_requires_raw_capture_hash" + + +def test_oss_worked_example_source_matrix_is_indexed_without_truth_upgrade(): + index = json.loads(INDEX.read_text(encoding="utf-8")) + packet = { + row["packet_id"]: row for row in index["packets"] + }["oss_worked_example_source_matrix_2026_07_20"] + assert packet["claim_status"] == "source_intake_only" + assert packet["consumer_policy"] == "research_observation_only" diff --git a/tests/test_prashna_input_contract_and_oracle_queue.py b/tests/test_prashna_input_contract_and_oracle_queue.py new file mode 100644 index 00000000..59e38f8f --- /dev/null +++ b/tests/test_prashna_input_contract_and_oracle_queue.py @@ -0,0 +1,36 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +CONTRACT = ROOT / "references/oracle/prashna_input_contract_2026_07_20.json" +QUEUE = ROOT / "references/oracle/prashna_numeric_oracle_packet_queue_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_contract_requires_time_place_timezone_ayanamsa_node(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_oracle_queue.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + contract = data["contract"] + assert contract["scope"] == "prashna_input_contract" + required = {row["field"] for row in contract["required_fields"]} + assert {"question_datetime_local", "location", "timezone", "ayanamsa", "node_mode"}.issubset(required) + assert contract["claim_status"] == "ready_contract" + + +def test_prashna_queue_keeps_public_numeric_examples_candidate_only(): + queue = json.loads(QUEUE.read_text(encoding="utf-8")) + assert queue["scope"] == "prashna_numeric_oracle_packet_queue" + assert queue["claim_status"] == "open_queue" + assert queue["summary"]["numeric_candidate_count"] >= 1 + assert queue["summary"]["oracle_ready_count"] == 0 + example = next(row for row in queue["rows"] if row["source_id"] == "vedastro_prasna_marga_ch5_sphuta_example") + assert example["numeric_fields_present"] is True + assert "trisphuta" in example["expected_values"] + assert "complete_prashna_input" in example["missing_for_oracle"] + assert example["upgrade_status"] == "candidate_not_oracle" + + +def test_prashna_contract_and_queue_are_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_input_contract_2026_07_20"]["claim_status"] == "ready_contract" + assert packets["prashna_numeric_oracle_packet_queue_2026_07_20"]["claim_status"] == "open_queue" diff --git a/tests/test_prashna_marga_excerpt_locator.py b/tests/test_prashna_marga_excerpt_locator.py new file mode 100644 index 00000000..47856d4d --- /dev/null +++ b/tests/test_prashna_marga_excerpt_locator.py @@ -0,0 +1,32 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/oracle/prashna_marga_excerpt_locator_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_marga_excerpt_locator_finds_keyword_windows_without_vendoring_text(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_marga_excerpt_locator.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + assert data["scope"] == "prashna_marga_excerpt_locator" + assert data["claim_status"] == "source_intake_only" + assert data["summary"]["located_window_count"] >= 1 + assert data["summary"]["oracle_ready_count"] == 0 + row = data["located_windows"][0] + assert row["window_hash"] + assert row["line_start"] > 0 + assert row["line_end"] >= row["line_start"] + assert len(row["short_context"]) < 260 + + +def test_prashna_marga_excerpt_locator_keeps_mismatch_queue_open(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + assert data["upgrade_status"] == "candidate_not_oracle" + assert "raw excerpt capture" in data["next_steps"][0] + assert "complete_prashna_input" in data["missing_for_oracle"] + + +def test_prashna_marga_excerpt_locator_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_marga_excerpt_locator_2026_07_20"]["claim_status"] == "source_intake_only" diff --git a/tests/test_prashna_marga_raw_capture_packet.py b/tests/test_prashna_marga_raw_capture_packet.py new file mode 100644 index 00000000..40417dc7 --- /dev/null +++ b/tests/test_prashna_marga_raw_capture_packet.py @@ -0,0 +1,31 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/oracle/prashna_marga_raw_capture_packet_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_marga_raw_capture_packet_pins_internet_archive_files(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_marga_raw_capture_packet.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + assert data["scope"] == "prashna_marga_raw_capture_packet" + assert data["claim_status"] == "source_intake_only" + assert data["summary"]["ia_item_count"] >= 2 + assert data["summary"]["oracle_ready_count"] == 0 + ids = {row["identifier"] for row in data["internet_archive_items"]} + assert "PrasnaMargaBVR" in ids + bvr = next(row for row in data["internet_archive_items"] if row["identifier"] == "PrasnaMargaBVR") + assert any(file["format"] == "DjVuTXT" and file["sha1"] for file in bvr["files"]) + + +def test_prashna_marga_raw_capture_packet_preserves_no_truth_upgrade_boundary(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + assert all(row["upgrade_status"] == "candidate_not_oracle" for row in data["internet_archive_items"]) + assert "Trisphuta" in data["field_locator_terms"] + assert "raw excerpt capture" in data["next_steps"][0] + + +def test_prashna_marga_raw_capture_packet_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_marga_raw_capture_packet_2026_07_20"]["claim_status"] == "source_intake_only" diff --git a/tests/test_prashna_sphuta_closure_dashboard.py b/tests/test_prashna_sphuta_closure_dashboard.py new file mode 100644 index 00000000..8e81c894 --- /dev/null +++ b/tests/test_prashna_sphuta_closure_dashboard.py @@ -0,0 +1,32 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/oracle/prashna_sphuta_closure_dashboard_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_sphuta_closure_dashboard_summarizes_chain_and_blockers(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_sphuta_closure_dashboard.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + assert data["scope"] == "prashna_sphuta_closure_dashboard" + assert data["claim_status"] == "blocked_until_human_labels" + assert data["summary"]["packet_chain_count"] >= 9 + assert data["summary"]["truth_upgrade_count"] == 0 + assert data["summary"]["blocked_gate_count"] >= 2 + gates = {gate["gate_id"]: gate for gate in data["gates"]} + assert gates["human_line_review"]["status"] == "blocked" + assert gates["complete_prashna_input"]["status"] == "blocked" + + +def test_prashna_sphuta_closure_dashboard_preserves_commercial_boundary(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + assert data["commercial_sync_status"] == "research_observation_only" + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + assert "do_not_use_for_deterministic_prashna_verdict" in data["forbidden_uses"] + + +def test_prashna_sphuta_closure_dashboard_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_sphuta_closure_dashboard_2026_07_20"]["claim_status"] == "blocked_until_human_labels" diff --git a/tests/test_prashna_sphuta_line_review_queue.py b/tests/test_prashna_sphuta_line_review_queue.py new file mode 100644 index 00000000..543b927c --- /dev/null +++ b/tests/test_prashna_sphuta_line_review_queue.py @@ -0,0 +1,33 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/oracle/prashna_sphuta_line_review_queue_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_sphuta_line_review_queue_creates_human_review_tasks(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_sphuta_line_review_queue.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + assert data["scope"] == "prashna_sphuta_line_review_queue" + assert data["claim_status"] == "open_queue" + assert data["summary"]["review_task_count"] >= 2 + assert data["summary"]["truth_upgrade_count"] == 0 + task = data["review_tasks"][0] + assert task["review_status"] == "needs_human_or_second_source_review" + assert "chatusphuta" in task["fields_to_check"] + assert task["window_hash"] + + +def test_prashna_sphuta_line_review_queue_has_explicit_acceptance_criteria(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + criteria = data["acceptance_criteria"] + assert "do_not_copy_long_text" in criteria + assert "record_line_coordinates" in criteria + assert "classify_formula_variant_or_transcription" in criteria + assert all(len(task["short_context"]) < 260 for task in data["review_tasks"]) + + +def test_prashna_sphuta_line_review_queue_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_sphuta_line_review_queue_2026_07_20"]["claim_status"] == "open_queue" diff --git a/tests/test_prashna_sphuta_oss_case_probe.py b/tests/test_prashna_sphuta_oss_case_probe.py new file mode 100644 index 00000000..4196fa82 --- /dev/null +++ b/tests/test_prashna_sphuta_oss_case_probe.py @@ -0,0 +1,30 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/oracle/prashna_sphuta_oss_case_probe_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_sphuta_oss_case_probe_runs_pyjhora_case_in_isolation(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_sphuta_oss_case_probe.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + assert data["scope"] == "prashna_sphuta_oss_case_probe" + assert data["claim_status"] == "tooling_observation_only" + assert data["license_boundary"] == "agpl_observation_only_do_not_vendor" + assert data["case"]["source"] == "jhora.tests.pvr_tests.sphuta_tests" + assert {r["field"] for r in data["rows"]} >= {"tri_sphuta", "chatur_sphuta", "pancha_sphuta"} + assert data["raw_hash"] + + +def test_prashna_sphuta_oss_case_probe_does_not_upgrade_oracle_truth(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + assert data["oracle_ready"] is False + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + assert all(row["status"] in {"observed", "runtime_error"} for row in data["rows"]) + + +def test_prashna_sphuta_oss_case_probe_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_sphuta_oss_case_probe_2026_07_20"]["claim_status"] == "tooling_observation_only" diff --git a/tests/test_prashna_sphuta_review_result_template.py b/tests/test_prashna_sphuta_review_result_template.py new file mode 100644 index 00000000..91e9bbf5 --- /dev/null +++ b/tests/test_prashna_sphuta_review_result_template.py @@ -0,0 +1,31 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/oracle/prashna_sphuta_review_result_template_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_sphuta_review_result_template_is_blank_and_schema_complete(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_sphuta_review_result_template.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + assert data["scope"] == "prashna_sphuta_review_result_template" + assert data["claim_status"] == "blocked_until_human_labels" + assert data["summary"]["template_count"] >= 2 + assert data["summary"]["completed_review_count"] == 0 + row = data["templates"][0] + assert row["review_result"] is None + assert row["allowed_results"] == ["formula_variant", "source_transcription", "naming_variant", "insufficient_evidence"] + assert "reviewer_id" in row["required_human_fields"] + + +def test_prashna_sphuta_review_result_template_has_no_truth_upgrade(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + assert data["upgrade_policy"] == "no_upgrade_until_completed_review_and_replay" + + +def test_prashna_sphuta_review_result_template_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_sphuta_review_result_template_2026_07_20"]["claim_status"] == "blocked_until_human_labels" diff --git a/tests/test_prashna_sphuta_review_result_validator.py b/tests/test_prashna_sphuta_review_result_validator.py new file mode 100644 index 00000000..f4bacd76 --- /dev/null +++ b/tests/test_prashna_sphuta_review_result_validator.py @@ -0,0 +1,31 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/oracle/prashna_sphuta_review_result_validation_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_sphuta_review_result_validator_blocks_blank_templates(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_sphuta_review_result_validator.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + assert data["scope"] == "prashna_sphuta_review_result_validation" + assert data["claim_status"] == "blocked_until_human_labels" + assert data["summary"]["template_count"] >= 2 + assert data["summary"]["valid_completed_review_count"] == 0 + assert data["summary"]["replay_gate_ready_count"] == 0 + row = data["validation_rows"][0] + assert row["validation_status"] == "blocked_missing_human_review" + assert "review_result" in row["missing_fields"] + + +def test_prashna_sphuta_review_result_validator_preserves_allowed_result_contract(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + assert data["allowed_results"] == ["formula_variant", "source_transcription", "naming_variant", "insufficient_evidence"] + assert data["replay_gate_policy"] == "requires_valid_completed_review_and_complete_prashna_input" + assert data["truth_matrix_allowed"] is False + + +def test_prashna_sphuta_review_result_validator_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_sphuta_review_result_validation_2026_07_20"]["claim_status"] == "blocked_until_human_labels" diff --git a/tests/test_prashna_sphuta_source_comparison_matrix.py b/tests/test_prashna_sphuta_source_comparison_matrix.py new file mode 100644 index 00000000..25b4bc52 --- /dev/null +++ b/tests/test_prashna_sphuta_source_comparison_matrix.py @@ -0,0 +1,31 @@ +import json +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/oracle/prashna_sphuta_source_comparison_matrix_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_prashna_sphuta_source_comparison_matrix_classifies_each_field(): + data = json.loads(subprocess.check_output(["python3", "scripts/prashna_sphuta_source_comparison_matrix.py", "--date", "2026-07-20"], cwd=ROOT, text=True)) + assert data["scope"] == "prashna_sphuta_source_comparison_matrix" + assert data["claim_status"] == "open_queue" + fields = {row["field"]: row for row in data["field_rows"]} + assert fields["trisphuta"]["local_vs_vedastro_status"] == "match" + assert fields["chatusphuta"]["local_vs_vedastro_status"] == "mismatch" + assert fields["panchasphuta"]["local_vs_vedastro_status"] == "mismatch" + assert fields["gulika"]["local_vs_vedastro_status"] == "input_value_only" + assert data["summary"]["truth_upgrade_count"] == 0 + + +def test_prashna_sphuta_source_comparison_matrix_links_ia_windows(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + assert data["ia_excerpt_window_count"] >= 1 + assert all(row["ia_excerpt_status"] in {"located_context", "not_field_specific"} for row in data["field_rows"]) + assert data["next_evidence"] == ["line-level transcription review", "complete Prashna input", "legal external replay"] + + +def test_prashna_sphuta_source_comparison_matrix_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + assert packets["prashna_sphuta_source_comparison_matrix_2026_07_20"]["claim_status"] == "open_queue" diff --git a/tests/test_preflight_fragment_scan.py b/tests/test_preflight_fragment_scan.py index c785b6b7..ff3cd65b 100644 --- a/tests/test_preflight_fragment_scan.py +++ b/tests/test_preflight_fragment_scan.py @@ -73,7 +73,11 @@ def test_preflight_fragment_scan_preserves_audit_capability_and_oracle_boundarie assert audit["capability_audit"]["valid"] is True assert audit["capability_audit"]["technique_count"] >= 89 assert audit["fragment_audit"]["valid"] is True - assert audit["fragment_audit"]["candidate_count"] == 0 + assert audit["fragment_audit"]["candidate_count"] >= ( + report["summary"]["high_value_unpromoted_count"] + + report["summary"]["workspace_residue_count"] + ) + assert audit["fragment_audit"]["workspace_residue_count"] == report["summary"]["workspace_residue_count"] oracle_boundary = report["real_capability_boundary"]["oracle_boundary"] assert oracle_boundary["scope"] == "external_oracle_boundary_audit" diff --git a/tests/test_production_health_smoke.py b/tests/test_production_health_smoke.py index 6e02ff1e..b3b210d7 100644 --- a/tests/test_production_health_smoke.py +++ b/tests/test_production_health_smoke.py @@ -26,3 +26,15 @@ def test_production_smoke_accepts_health_degraded_status(monkeypatch) -> None: assert report["ok"] is True assert report["checks"][1]["health_status"] == "blocked" + + +def test_production_smoke_requires_the_expected_deployment_sha(monkeypatch) -> None: + def fake_fetch(url: str, timeout: float) -> tuple[int, str, float]: + if url.endswith("/api/health"): + return 200, '{"status":"ok","deployment":{"gitCommit":"old-sha"}}', 0.01 + return 200, "Jyotisha", 0.01 + + monkeypatch.setattr("scripts.production_smoke.fetch", fake_fetch) + report = check("https://example.invalid", 1.0, expected_git_sha="new-sha") + assert report["ok"] is False + assert report["checks"][1]["deployment_git_commit"] == "old-sha" diff --git a/tests/test_public_worked_example_source_triage.py b/tests/test_public_worked_example_source_triage.py new file mode 100644 index 00000000..b5db18dc --- /dev/null +++ b/tests/test_public_worked_example_source_triage.py @@ -0,0 +1,43 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references" / "oracle" / "public_worked_example_source_triage_2026_07_20.json" +INDEX = ROOT / "references" / "oracle" / "evidence_packet_index_2026_07_19.json" + + +def test_public_worked_example_source_triage_classifies_web_sources(): + data = json.loads( + subprocess.check_output( + ["python3", "scripts/public_worked_example_source_triage.py", "--date", "2026-07-20"], + cwd=ROOT, + text=True, + ) + ) + assert data["scope"] == "public_worked_example_source_triage" + assert data["claim_status"] == "source_intake_only" + assert data["summary"]["source_count"] >= 5 + assert data["summary"]["numeric_candidate_count"] >= 2 + assert data["summary"]["oracle_ready_count"] == 0 + by_id = {row["source_id"]: row for row in data["sources"]} + assert by_id["mypanchang_edison_2025_panchangam"]["numeric_fields_present"] is True + assert by_id["drikpanchang_mumbai_rahu_2026_07_20"]["numeric_fields_present"] is True + assert by_id["mypanchang_tarabalam_chakra"]["source_role"] == "formula_reference" + + +def test_public_worked_example_source_triage_preserves_boundaries_and_hashes(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + assert all(row["observation_hash"] for row in data["sources"]) + assert all(row["upgrade_status"] != "oracle_ready" for row in data["sources"]) + rahu = next(row for row in data["sources"] if row["source_id"] == "drikpanchang_mumbai_rahu_2026_07_20") + assert "sunrise" in rahu["missing_for_oracle"] + assert "raw_capture_hash" in rahu["missing_for_oracle"] + + +def test_public_worked_example_source_triage_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + packet = packets["public_worked_example_source_triage_2026_07_20"] + assert packet["domain"] == "worked_example_collection" + assert packet["claim_status"] == "source_intake_only" diff --git a/tests/test_real_case_website_e2e_eval.py b/tests/test_real_case_website_e2e_eval.py new file mode 100644 index 00000000..6546a920 --- /dev/null +++ b/tests/test_real_case_website_e2e_eval.py @@ -0,0 +1,32 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references/real_case_calibration/real_case_website_e2e_eval_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_real_case_website_e2e_eval_builds_twenty_case_contract(): + data = json.loads(subprocess.check_output(["python3", "scripts/real_case_website_e2e_eval.py"], cwd=ROOT, text=True)) + assert data["scope"] == "real_case_website_e2e_eval" + assert data["case_count"] == 20 + assert data["claim_status"] == "ready_contract" + assert data["truth_matrix_allowed"] is False + assert data["production_tuning_allowed"] is False + + +def test_real_case_website_e2e_eval_requires_core_runtime_context(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + required = {"D1", "Dasha", "functional_benefic_malefic", "claim_boundary", "similar_case_reference_allowed"} + assert all(required <= set(case["expected_runtime_context"]) for case in data["cases"]) + domains = {domain for case in data["cases"] for domain in case["domains"]} + assert {"career", "wealth", "marriage", "health", "migration", "family", "education", "timing", "annual"} <= domains + + +def test_real_case_website_e2e_eval_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + packet = packets["real_case_website_e2e_eval_2026_07_20"] + assert packet["claim_status"] == "ready_contract" + assert "not an accuracy benchmark" in packet["claim_boundary"] diff --git a/tests/test_research_web_skill_commercial_gap_registry.py b/tests/test_research_web_skill_commercial_gap_registry.py new file mode 100644 index 00000000..3e1e9d8e --- /dev/null +++ b/tests/test_research_web_skill_commercial_gap_registry.py @@ -0,0 +1,36 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +REGISTRY = ROOT / "references/oracle/research_web_skill_commercial_gap_registry_2026_07_20.json" + + +def test_research_web_skill_commercial_gap_registry_separates_research_and_commercial_runtime(): + data = json.loads(REGISTRY.read_text(encoding="utf-8")) + assert data["scope"] == "research_web_skill_commercial_gap_registry" + assert data["claim_status"] == "open_queue" + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + + commercial = data["current_state"]["commercial_repo_patterns_to_learn"] + assert commercial["source_status"] == "read_only_dirty_worktree_do_not_copy_directly" + blocked = set(commercial["must_not_import"]) + assert "Supabase runtime" in blocked + assert "credits/payment/subscription logic" in blocked + assert "commercial user data" in blocked + + +def test_research_web_skill_gap_registry_orders_numeric_packets_before_ui_truth_upgrade(): + data = json.loads(REGISTRY.read_text(encoding="utf-8")) + order = data["recommended_order"] + assert order[:3] == [ + "worked_examples_to_numeric_packets", + "component_closure", + "claim_gate_upgrade", + ] + + tracks = {row["track"]: row for row in data["delivery_queue"]} + assert tracks["research_web_profile_flow"]["status"] == "planned" + assert "localStorage only" in tracks["research_web_profile_flow"]["next_delivery"] + assert "exploratory" in tracks["research_web_rectification_journey"]["gate_for_upgrade"] + assert "skill_truth_overlay" in tracks["skill_to_web_sync"]["next_delivery"] diff --git a/tests/test_session_management_entrypoints.py b/tests/test_session_management_entrypoints.py index a9a550c8..09803611 100644 --- a/tests/test_session_management_entrypoints.py +++ b/tests/test_session_management_entrypoints.py @@ -3,16 +3,23 @@ from pathlib import Path PAGE = Path("frontend/src/app/page.tsx") STYLES = Path("frontend/src/app/globals.css") +SESSION_ROW = Path("frontend/src/components/sidebar-session-row.tsx") +SESSION_DELETE_ROUTE = Path("frontend/src/app/api/sessions/[id]/route.ts") +SESSION_DELETE_MIGRATION = Path("frontend/supabase/migrations/20260721100000_chat_sessions_delete_grant.sql") def test_chat_history_management_actions_are_exposed() -> None: - source = PAGE.read_text(encoding="utf-8") + STYLES.read_text(encoding="utf-8") + source = PAGE.read_text(encoding="utf-8") + STYLES.read_text(encoding="utf-8") + SESSION_ROW.read_text(encoding="utf-8") for expected in ( "renameSession", "deleteSession", + "pendingSessionDeletion", + "确认删除", + "session-delete-overlay", "togglePinnedSession", "toggleArchivedSession", "showArchivedSessions", + "已归档,可在左侧归档中恢复。", "shareSession", "share_payload_version", "messages.map", @@ -28,5 +35,26 @@ def test_chat_history_management_actions_are_exposed() -> None: "恢复", "删除", "转发", + 'fetch(`/api/sessions/${encodeURIComponent(session.id)}`', ): assert expected in source + + +def test_chat_session_delete_is_server_controlled_and_granted() -> None: + route = SESSION_DELETE_ROUTE.read_text(encoding="utf-8") + migration = SESSION_DELETE_MIGRATION.read_text(encoding="utf-8") + assert 'from("chat_sessions")' in route + assert '.eq("user_id", user.id)' in route + assert 'count !== 1' in route + assert 'grant delete on table public.chat_sessions to authenticated' in migration.lower() + assert 'create policy chat_sessions_delete_own' in migration.lower() + assert 'using ((select auth.uid()) = user_id)' in migration.lower() + + +def test_archiving_never_calls_the_delete_endpoint() -> None: + source = PAGE.read_text(encoding="utf-8") + start = source.index("function toggleArchivedSession") + end = source.index("async function shareSession", start) + archive_action = source[start:end] + assert "setArchivedSessionIds" in archive_action + assert "/api/sessions/" not in archive_action diff --git a/tests/test_shadbala_chesta_variant_packet.py b/tests/test_shadbala_chesta_variant_packet.py new file mode 100644 index 00000000..f47b4759 --- /dev/null +++ b/tests/test_shadbala_chesta_variant_packet.py @@ -0,0 +1,41 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PACKET = ROOT / "references/oracle/shadbala_chesta_variant_packet_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_chesta_packet_keeps_method_variants_separate_from_formula_mismatch(): + data = json.loads(PACKET.read_text(encoding="utf-8")) + assert data["scope"] == "shadbala_chesta_variant_packet" + assert data["component"] == "chesta" + assert data["claim_status"] == "partial" + assert data["closure_classification"] == "method_variant_mixed_with_formula_mismatch" + assert data["absolute_parity_ready"] is False + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + assert data["summary"]["component_row_count"] == 7 + assert data["summary"]["method_variant_count"] == 6 + assert data["summary"]["formula_or_unit_mismatch_count"] == 1 + assert data["summary"]["within_tolerance_count"] == 0 + + +def test_chesta_packet_records_luminary_policy_conflict_and_venus_exception(): + data = json.loads(PACKET.read_text(encoding="utf-8")) + rows = {row["planet"]: row for row in data["rows"]} + assert rows["Sun"]["variant_family"] == "luminary_chesta_policy_conflict" + assert rows["Moon"]["variant_family"] == "luminary_chesta_policy_conflict" + assert rows["Venus"]["closure_classification"] == "formula_or_unit_mismatch" + assert rows["Venus"]["next_evidence_owner"] == "formula_source_arbitration" + for planet in ["Mars", "Mercury", "Jupiter", "Saturn"]: + assert rows[planet]["variant_family"] == "mean_motion_seeghrochcha_variant" + assert rows[planet]["closure_classification"] == "method_variant" + + +def test_chesta_packet_is_indexed_as_partial_component_closure(): + index = json.loads(INDEX.read_text(encoding="utf-8")) + packets = {row["packet_id"]: row for row in index["packets"]} + packet = packets["shadbala_chesta_variant_packet_2026_07_20"] + assert packet["claim_status"] == "partial" + assert packet["domain"] == "shadbala_component_closure" diff --git a/tests/test_shadbala_digbala_formula_packet.py b/tests/test_shadbala_digbala_formula_packet.py new file mode 100644 index 00000000..fb53a66f --- /dev/null +++ b/tests/test_shadbala_digbala_formula_packet.py @@ -0,0 +1,41 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PACKET = ROOT / "references/oracle/shadbala_digbala_formula_packet_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_digbala_packet_classifies_all_rows_as_formula_or_unit_mismatch(): + data = json.loads(PACKET.read_text(encoding="utf-8")) + assert data["scope"] == "shadbala_digbala_formula_packet" + assert data["component"] == "dig" + assert data["claim_status"] == "partial" + assert data["closure_classification"] == "formula_or_unit_mismatch" + assert data["absolute_parity_ready"] is False + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + assert data["summary"]["component_row_count"] == 7 + assert data["summary"]["formula_or_unit_mismatch_count"] == 7 + assert data["summary"]["within_tolerance_count"] == 0 + + +def test_digbala_packet_splits_mismatch_families_without_majority_vote(): + data = json.loads(PACKET.read_text(encoding="utf-8")) + rows = {row["planet"]: row for row in data["rows"]} + assert rows["Moon"]["mismatch_family"] == "local_formula_outlier" + assert rows["Mars"]["mismatch_family"] == "local_formula_outlier" + assert rows["Jupiter"]["mismatch_family"] == "local_formula_outlier" + assert rows["Saturn"]["mismatch_family"] == "small_delta_still_unfrozen" + for planet, row in rows.items(): + assert row["next_evidence_owner"] == "formula_source_arbitration" + assert "house-cusp vs whole-house angular distance" in row["known_variants"] + assert row["claim_boundary"].startswith("Do not tune Digbala") + + +def test_digbala_packet_is_indexed_as_partial_component_closure(): + index = json.loads(INDEX.read_text(encoding="utf-8")) + packets = {row["packet_id"]: row for row in index["packets"]} + packet = packets["shadbala_digbala_formula_packet_2026_07_20"] + assert packet["claim_status"] == "partial" + assert packet["domain"] == "shadbala_component_closure" diff --git a/tests/test_shadbala_naisargika_closure_packet.py b/tests/test_shadbala_naisargika_closure_packet.py new file mode 100644 index 00000000..304f6224 --- /dev/null +++ b/tests/test_shadbala_naisargika_closure_packet.py @@ -0,0 +1,43 @@ +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PACKET = ROOT / "references/oracle/shadbala_naisargika_closure_packet_2026_07_20.json" +INDEX = ROOT / "references/oracle/evidence_packet_index_2026_07_19.json" + + +def test_naisargika_packet_closes_same_unit_observation_without_truth_upgrade(): + data = json.loads(PACKET.read_text(encoding="utf-8")) + assert data["scope"] == "shadbala_naisargika_closure_packet" + assert data["component"] == "naisargika" + assert data["claim_status"] == "partial" + assert data["closure_classification"] == "within_tolerance_observation" + assert data["absolute_parity_ready"] is False + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + assert data["summary"] == { + "component_row_count": 7, + "max_delta_virupa": 0.0, + "source_count_per_row": 5, + "within_tolerance_count": 7, + } + + +def test_naisargika_packet_has_all_visible_planets_and_unit_contract(): + data = json.loads(PACKET.read_text(encoding="utf-8")) + rows = {row["planet"]: row for row in data["rows"]} + assert set(rows) == {"Sun", "Moon", "Mars", "Mercury", "Jupiter", "Venus", "Saturn"} + for row in rows.values(): + values = row["normalized_values_virupa"] + assert set(values) == {"local", "jyotishganit", "xalen", "vp_jain_local", "vp_jain_published"} + assert max(values.values()) - min(values.values()) == 0 + assert row["unit_contract"] == "Virupa fixed natural-strength table; 60 Virupa = 1 Rupa." + assert row["closure_status"] == "same_unit_observation_frozen" + + +def test_naisargika_packet_is_indexed_as_partial_component_closure(): + index = json.loads(INDEX.read_text(encoding="utf-8")) + packets = {row["packet_id"]: row for row in index["packets"]} + packet = packets["shadbala_naisargika_closure_packet_2026_07_20"] + assert packet["claim_status"] == "partial" + assert packet["domain"] == "shadbala_component_closure" diff --git a/tests/test_supabase_profile_migration_workflow.py b/tests/test_supabase_profile_migration_workflow.py index 622c8d60..1b518975 100644 --- a/tests/test_supabase_profile_migration_workflow.py +++ b/tests/test_supabase_profile_migration_workflow.py @@ -17,11 +17,20 @@ def test_profile_migration_workflow_is_manual_and_uses_vps_env_without_printing_ assert "cat \"$SQL_FILE\" |" in text -def test_profile_migration_workflow_targets_only_account_profile_migrations() -> None: +def test_profile_migration_workflow_includes_chart_library_and_birth_time_profile_migrations() -> None: text = WORKFLOW.read_text(encoding="utf-8") - assert "20260718010000_recover_missing_profile_rows.sql" in text - assert "20260718020000_profiles_service_role_upsert_grants.sql" in text assert "20260718050000_profiles_service_role_upsert_grants.sql" in text + assert "20260718060000_profiles_service_role_least_privilege.sql" in text assert "20260718070000_profiles_service_role_upsert_id.sql" in text assert "20260718080000_profiles_service_role_account_upsert_selects.sql" in text + assert "20260718100000_repair_missing_chart_profiles.sql" in text + assert "20260718102000_recover_missing_profile_rows.sql" in text + assert "20260718103000_profile_birth_time_declaration_grants.sql" in text + assert "20260718104000_chart_profiles_upsert_id_grant.sql" in text + + +def test_profile_migration_workflow_does_not_reference_missing_sql_files() -> None: + text = WORKFLOW.read_text(encoding="utf-8") + assert "20260718010000_recover_missing_profile_rows.sql" not in text + assert "20260718020000_profiles_service_role_upsert_grants.sql" not in text diff --git a/tests/test_supabase_user_data_contract.py b/tests/test_supabase_user_data_contract.py index 0109702b..43e7000b 100644 --- a/tests/test_supabase_user_data_contract.py +++ b/tests/test_supabase_user_data_contract.py @@ -38,6 +38,7 @@ SYNASTRY_REPORT_MIGRATION = ( / "20260718101000_repair_missing_synastry_reports.sql" ) PAGE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "page.tsx" +ACCOUNT_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "account" / "route.ts" CHART_PROFILE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "route.ts" CHART_PROFILE_DELETE_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "chart-profiles" / "[id]" / "route.ts" SYNASTRY_ROUTE = Path(__file__).resolve().parents[1] / "frontend" / "src" / "app" / "api" / "synastry" / "route.ts" @@ -115,6 +116,12 @@ def test_chat_page_uses_authenticated_cloud_persistence() -> None: assert 'localStorage.setItem("chat_sessions"' not in source +def test_account_profile_patch_rejects_array_payloads() -> None: + route = ACCOUNT_ROUTE.read_text(encoding="utf-8") + assert 'typeof payload !== "object" || Array.isArray(payload)' in route + assert "账户资料格式不正确" in route + + def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None: sql = re.sub(r"\s+", " ", CHART_PROFILE_MIGRATION.read_text(encoding="utf-8").lower()).strip() route = CHART_PROFILE_ROUTE.read_text(encoding="utf-8") @@ -140,11 +147,16 @@ def test_chart_profile_library_has_cloud_table_api_and_local_fallback() -> None: 'from("chart_profiles")', 'eq("user_id", user.id)', 'eq("role", "self")', + "Array.isArray(body.profile)", 'insert({ user_id: user.id, role, profile: body.profile', - 'upsert(record, { onConflict: "id" })', + 'insert({ user_id: user.id, role, profile: body.profile, updated_at: updatedAt })', ): assert token in route + assert 'upsert(record, { onConflict: "id" })' not in route + assert '.delete({ count: "exact" })' in delete_route assert 'eq("role", "other")' in delete_route + assert "count !== 1" in delete_route + assert "星盘不存在或无权删除" in delete_route for token in ( "fetchCloudChartLibrary", diff --git a/tests/test_three_engine_worked_example_bridge.py b/tests/test_three_engine_worked_example_bridge.py new file mode 100644 index 00000000..699334cb --- /dev/null +++ b/tests/test_three_engine_worked_example_bridge.py @@ -0,0 +1,42 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references" / "oracle" / "three_engine_worked_example_bridge_2026_07_20.json" +INDEX = ROOT / "references" / "oracle" / "evidence_packet_index_2026_07_19.json" + + +def test_three_engine_worked_example_bridge_links_owner_tracks_to_intake_domains(): + data = json.loads( + subprocess.check_output( + ["python3", "scripts/three_engine_worked_example_bridge.py", "--date", "2026-07-20"], + cwd=ROOT, + text=True, + ) + ) + assert data["scope"] == "three_engine_worked_example_bridge" + assert data["claim_status"] == "open_queue" + assert data["production_tuning_allowed"] is False + tracks = {row["owner_track"]: row for row in data["owner_track_links"]} + assert "formula_source" in tracks + assert tracks["formula_source"]["linked_intake_domains"] == ["shadbala_component_closure"] + assert tracks["formula_source"]["ticket_count"] >= 30 + assert tracks["formula_source"]["claim_boundary"].startswith("Bridge only") + + +def test_three_engine_worked_example_bridge_identifies_unlinked_tracks(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + tracks = {row["owner_track"]: row for row in data["owner_track_links"]} + assert tracks["endpoint_contract"]["linked_intake_domains"] == [] + assert "method contract" in tracks["endpoint_contract"]["next_non_numeric_evidence"][0] + assert data["summary"]["linked_owner_track_count"] >= 1 + assert data["summary"]["closed_mismatch_count"] == 0 + + +def test_three_engine_worked_example_bridge_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + packet = packets["three_engine_worked_example_bridge_2026_07_20"] + assert packet["domain"] == "three_engine_parity" + assert packet["claim_status"] == "open_queue" diff --git a/tests/test_unified_consultation_orchestrator.py b/tests/test_unified_consultation_orchestrator.py index 865bec22..ed009ae7 100644 --- a/tests/test_unified_consultation_orchestrator.py +++ b/tests/test_unified_consultation_orchestrator.py @@ -19,6 +19,31 @@ def test_unified_consultation_orchestrator_normalizes_themes_and_route() -> None assert "D10" in route["focus_techniques"] +def test_unified_consultation_orchestrator_routes_health_questions() -> None: + orchestrator = UnifiedConsultationOrchestrator() + themes = orchestrator.normalize_themes(["health"]) + route = orchestrator.resolve_route("健康 health risk should stay non-medical", themes) + assert route["question_type"] == "health" + assert route["primary_theme"] == "health" + assert "D6" in route["focus_techniques"] + assert "non-medical boundary" in route["focus_techniques"] + + +def test_unified_consultation_orchestrator_routes_extended_product_domains() -> None: + orchestrator = UnifiedConsultationOrchestrator() + cases = [ + ("海外迁移 relocation", ["migration"], "migration", "D12"), + ("家庭子女 home children", ["family"], "family", "D7"), + ("教育学习 school degree", ["education"], "education", "D24"), + ("今年年度运势 annual forecast", ["annual"], "annual", "Tajika candidate"), + ] + for question, raw_themes, expected_route, expected_layer in cases: + themes = orchestrator.normalize_themes(raw_themes) + route = orchestrator.resolve_route(question, themes) + assert route["question_type"] == expected_route + assert expected_layer in route["focus_techniques"] + + def test_unified_consultation_orchestrator_exposes_surface_agnostic_contract() -> None: orchestrator = UnifiedConsultationOrchestrator() route = orchestrator.resolve_route("When will I marry?", ["marriage"]) diff --git a/tests/test_worked_example_packet_intake_plan.py b/tests/test_worked_example_packet_intake_plan.py new file mode 100644 index 00000000..a8a6aa5c --- /dev/null +++ b/tests/test_worked_example_packet_intake_plan.py @@ -0,0 +1,43 @@ +import json +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ARTIFACT = ROOT / "references" / "oracle" / "worked_example_packet_intake_plan_2026_07_20.json" +INDEX = ROOT / "references" / "oracle" / "evidence_packet_index_2026_07_19.json" + + +def test_worked_example_packet_intake_plan_groups_candidates_by_domain(): + data = json.loads( + subprocess.check_output( + ["python3", "scripts/worked_example_packet_intake_plan.py", "--date", "2026-07-20"], + cwd=ROOT, + text=True, + ) + ) + assert data["scope"] == "worked_example_packet_intake_plan" + assert data["claim_status"] == "open_queue" + assert data["production_tuning_allowed"] is False + assert data["truth_matrix_allowed"] is False + assert data["summary"]["candidate_count"] >= 5 + assert data["summary"]["oracle_ready_count"] == 0 + domains = {row["domain"] for row in data["domain_queues"]} + assert {"kp_precision_timing", "shadbala_component_closure", "muhurta_factor_scoring"}.issubset(domains) + + +def test_worked_example_packet_intake_plan_preserves_blockers_and_next_actions(): + data = json.loads(ARTIFACT.read_text(encoding="utf-8")) + rows = {row["domain"]: row for row in data["domain_queues"]} + kp = rows["kp_precision_timing"] + assert kp["highest_status"] in {"runtime_only_public_oracle_missing", "reference_table_hash_needed"} + assert "public_numeric_expected_values" in kp["blocking_fields"] + assert kp["next_action_owner"] == "oracle_intake" + assert all(item["upgrade_policy"] == "observation_only_until_numeric_packet" for row in data["domain_queues"] for item in row["items"]) + + +def test_worked_example_packet_intake_plan_is_indexed(): + packets = {row["packet_id"]: row for row in json.loads(INDEX.read_text(encoding="utf-8"))["packets"]} + packet = packets["worked_example_packet_intake_plan_2026_07_20"] + assert packet["domain"] == "worked_example_collection" + assert packet["claim_status"] == "open_queue"