diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index 39ba19c4..517f4a70 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -6,6 +6,7 @@ on: - '.gitea/workflows/backend-quality-gate.yml' - '.gitea/workflows/deploy-staging.yml' - '.gitea/workflows/migrate-staging-database.yml' + - '.gitea/workflows/migrate-production-database.yml' - 'deploy/**' - 'frontend/**' - 'jyotish_vedic/**' @@ -32,6 +33,9 @@ jobs: timeout-minutes: 45 env: GITEA_SHA: ${{ gitea.sha }} + GITEA_EVENT_NAME: ${{ gitea.event_name }} + NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c + NODE_TOOL_IMAGE: node:22-bookworm-slim steps: - name: Checkout exact Gitea revision run: | @@ -96,9 +100,6 @@ jobs: docker compose --help | grep -q -- '--project-name' - name: Prepare pinned Node tooling - env: - NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c - NODE_TOOL_IMAGE: node:22-bookworm-slim run: | set -euo pipefail if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then @@ -167,7 +168,39 @@ jobs: "pandas==2.3.3" \ "timezonefinder==8.2.5" \ -r requirements.txt -r requirements-dev.txt - npm ci --prefix frontend + workdir="$(pwd -P)" + set +e + docker run --rm \ + --cpus=1.5 \ + --memory=2g \ + --memory-swap=2g \ + --pids-limit=256 \ + --user "$(id -u):$(id -g)" \ + --volume "$workdir:$workdir" \ + --workdir "$workdir" \ + --env HOME=/tmp \ + --env "NPM_CONFIG_REGISTRY=$NPM_CONFIG_REGISTRY" \ + "$NODE_TOOL_SOURCE_IMAGE" \ + timeout --signal=TERM --kill-after=30s 900s \ + npm ci --prefix frontend \ + --no-audit \ + --no-fund \ + --progress=false \ + --maxsockets=4 \ + --fetch-timeout=60000 \ + --fetch-retries=2 \ + --fetch-retry-mintimeout=1000 \ + --fetch-retry-maxtimeout=10000 + npm_ci_status=$? + set -e + if [ "$npm_ci_status" -eq 124 ]; then + echo "frontend npm ci exceeded bounded 900-second timeout; check npm mirror/network or dependency postinstall hang" >&2 + exit 124 + fi + if [ "$npm_ci_status" -ne 0 ]; then + echo "frontend npm ci failed with status $npm_ci_status inside bounded Node container" >&2 + exit "$npm_ci_status" + fi - name: Validate backend, package, frontend, and database contracts run: | @@ -183,16 +216,20 @@ jobs: python -m build npm test --prefix frontend npm run lint --prefix frontend - if ! timeout 600 npm run build --prefix frontend; then - echo "frontend production build exceeded bounded 600-second timeout" >&2 - exit 124 + if [ "$GITEA_EVENT_NAME" != "push" ]; then + if ! timeout 600 npm run build --prefix frontend -- --webpack; then + echo "frontend production build exceeded bounded 600-second timeout" >&2 + exit 124 + fi + else + echo "staging push production build is verified once by the publish image build" fi publish: if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/staging' needs: validate runs-on: manman-linux - timeout-minutes: 45 + timeout-minutes: 60 env: GITEA_SHA: ${{ gitea.sha }} GITEA_RUN_ATTEMPT: ${{ gitea.run_attempt }} diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml index 7ef96484..1a97ed24 100644 --- a/.gitea/workflows/deploy-production.yml +++ b/.gitea/workflows/deploy-production.yml @@ -1,59 +1,381 @@ -name: Deploy production (manual only) +name: Deploy production on: 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 + verification_mode: + description: Use internal before DNS cutover; public after DNS and TLS converge + required: true + default: internal + type: choice + options: + - internal + - public + +permissions: + contents: read + actions: read concurrency: - group: production + group: production-mutation cancel-in-progress: false - -env: - GITEA_SHA: ${{ gitea.sha }} - DEPLOY_HOST: 103.117.123.53 - DEPLOY_PORT: '22000' - DEPLOY_USER: root - DEPLOY_PATH: /opt/jyotisha-app + queue: max jobs: deploy: - runs-on: xiaoxin + runs-on: manman-linux timeout-minutes: 30 + env: + GITEA_SHA: ${{ gitea.sha }} + GITEA_API_URL: ${{ gitea.api_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com + IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha + DEPLOY_HOST: ${{ vars.PRODUCTION_HOST }} + DEPLOY_PORT: ${{ vars.PRODUCTION_PORT }} + DEPLOY_USER: ${{ vars.PRODUCTION_USER }} + DEPLOY_PATH: ${{ vars.PRODUCTION_PATH }} + PRODUCTION_URL: ${{ vars.PRODUCTION_URL }} + PRODUCTION_ADMIN_URL: ${{ vars.PRODUCTION_ADMIN_URL }} + STAGING_URL: ${{ vars.STAGING_URL }} + PRODUCTION_KNOWN_HOSTS: ${{ vars.PRODUCTION_KNOWN_HOSTS }} steps: - - name: Checkout current Gitea revision + - name: Validate tested revision and gate run + id: revision + env: + REQUESTED_SHA: ${{ inputs.deploy_sha }} + REQUESTED_ROLLBACK: ${{ inputs.allow_rollback }} + VERIFICATION_MODE: ${{ inputs.verification_mode }} run: | set -euo pipefail - git init . - git remote remove origin 2>/dev/null || true - git remote add origin https://git.copse.top/root/Jyotisha.git - git fetch --no-tags origin "$GITEA_SHA" main - git checkout --detach --force "$GITEA_SHA" - - name: Verify runner toolchain and current main + [[ "$REQUESTED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; } + [[ "$VERIFICATION_MODE" == internal || "$VERIFICATION_MODE" == public ]] || { echo "invalid verification_mode" >&2; exit 1; } + [[ "$STAGING_URL" == "https://staging.jyotisha.chat" ]] || { echo "unexpected staging acceptance URL" >&2; exit 1; } + allow_rollback=false + if [[ "$REQUESTED_ROLLBACK" == true ]]; then + allow_rollback=true + fi + + runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&branch=staging&event=push&status=success&limit=100")" + selected_run="$(jq -cer --arg sha "$REQUESTED_SHA" ' + [.workflow_runs[] | select( + (.path | split("@")[0] | endswith("backend-quality-gate.yml")) and + .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_id" =~ ^[0-9]+$ ]] || { echo "no successful exact-SHA production quality gate run found" >&2; exit 1; } + + read_ref_sha() { + local branch="$1" + curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" | + jq -er --arg ref "refs/heads/$branch" ' + select(type == "array" and length == 1) | + .[0] | select(.ref == $ref) | .object.sha | + select(test("^[0-9a-f]{40}$")) + ' + } + staging_head="$(read_ref_sha staging)" + controller_sha="$(read_ref_sha main)" + [[ "$controller_sha" == "$staging_head" ]] || { echo "main and staging must identify the same reviewed release" >&2; exit 1; } + if [[ "$allow_rollback" == false && "$REQUESTED_SHA" != "$staging_head" ]]; then + echo "stale production revision refused; use explicit manual rollback only when intended" >&2 + exit 1 + fi + if [[ "$allow_rollback" == true && "$REQUESTED_SHA" != "$controller_sha" ]]; then + comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$REQUESTED_SHA...$controller_sha")" + jq -e --arg base "$REQUESTED_SHA" --arg head "$controller_sha" ' + (.commits // []) as $commits | + def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha]; + def reaches($sha; $seen): + if $sha == $base then true + elif ($seen | index($sha)) != null then false + else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end; + (.total_commits | type) == "number" and + .total_commits == ($commits | length) and ($commits | length) > 0 and + ([$commits[].sha] | length == (unique | length)) and reaches($head; []) + ' <<<"$comparison" >/dev/null || { echo "rollback revision is not in reviewed main history" >&2; exit 1; } + fi + + controller_gate_run_id="$gate_run_id" + if [[ "$controller_sha" != "$REQUESTED_SHA" ]]; then + controller_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$controller_sha&branch=staging&event=push&status=success&limit=100")" + controller_run="$(jq -cer --arg sha "$controller_sha" ' + [.workflow_runs[] | select( + (.path | split("@")[0] | endswith("backend-quality-gate.yml")) and + .head_sha == $sha and .head_branch == "staging" and + .event == "push" and .conclusion == "success" + )] | sort_by(.id) | reverse | first + ' <<<"$controller_runs")" + controller_gate_run_id="$(jq -er '.id' <<<"$controller_run")" + fi + [[ "$controller_gate_run_id" =~ ^[0-9]+$ ]] + + release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$REQUESTED_SHA&event=workflow_dispatch&status=success&limit=100")" + jq -e --arg sha "$REQUESTED_SHA" ' + any(.workflow_runs[]?; + (.path | split("@")[0] | endswith("release-quality-gate.yml")) and + .head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success" + ) + ' <<<"$release_runs" >/dev/null || { echo "no successful exact-SHA manual release quality gate found" >&2; exit 1; } + + if [[ "$allow_rollback" == false ]]; then + observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \ + "$STAGING_URL/api/health" | jq -er '.deployment.gitCommit | select(test("^[0-9a-f]{40}$"))')" + [[ "$observed_staging_sha" == "$REQUESTED_SHA" ]] || { echo "public staging has not accepted the requested SHA" >&2; exit 1; } + fi + { + echo "sha=$REQUESTED_SHA" + echo "gate_run_id=$gate_run_id" + echo "controller_sha=$controller_sha" + echo "controller_gate_run_id=$controller_gate_run_id" + echo "allow_rollback=$allow_rollback" + echo "verification_mode=$VERIFICATION_MODE" + } >>"$GITHUB_OUTPUT" + + - name: Prepare pinned Node tooling + env: + NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c + NODE_TOOL_IMAGE: node:22-bookworm-slim run: | set -euo pipefail - python3 --version + if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then + for attempt in 1 2 3; do + if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2 + exit 1 + fi + sleep $((attempt * 15)) + done + fi + docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE" + docker image inspect "$NODE_TOOL_IMAGE" >/dev/null + tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")" + cat > "$tool_dir/node" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + workdir="$(pwd -P)" + exec docker run --rm \ + --user "$(id -u):$(id -g)" \ + --volume "$workdir:$workdir" \ + --workdir "$workdir" \ + --env HOME=/tmp \ + node:22-bookworm-slim "${0##*/}" "$@" + EOF + chmod 0755 "$tool_dir/node" + ln -s node "$tool_dir/npm" + test -n "${GITHUB_PATH:-}" + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + export PATH="$tool_dir:$PATH" node --version npm --version - docker version - test "$(git rev-parse HEAD)" = "$(git ls-remote origin refs/heads/main | awk '{print $1}')" - - name: Configure pinned production SSH + + - name: Download target and controller gate artifacts env: - SSH_PRIVATE_KEY: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }} + TARGET_GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }} + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + CONTROLLER_GATE_RUN_ID: ${{ steps.revision.outputs.controller_gate_run_id }} + CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }} run: | set -euo pipefail - install -m 700 -d ~/.ssh - printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production - chmod 600 ~/.ssh/jyotisha-production - printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts - - name: Sync and rebuild reviewed revision + download_bundle() { + local run_id="$1" sha="$2" destination="$3" zip_path="$4" + local prefix artifacts selected name id attempt + prefix="staging-image-manifest-$sha-" + artifacts="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$run_id/artifacts?limit=100")" + selected="$(jq -cer --arg prefix "$prefix" ' + [(.artifacts // [])[] + | select(.expired == false and (.name | startswith($prefix))) + | . + {attempt: ((.name | ltrimstr($prefix)) | tonumber?)} + | select(.attempt != null and .attempt >= 1) + ] | sort_by(.attempt, .id) | reverse | first + ' <<<"$artifacts")" + name="$(jq -er '.name' <<<"$selected")" + id="$(jq -er '.id' <<<"$selected")" + attempt="${name#"$prefix"}" + [[ "$name" == "$prefix"* && "$attempt" =~ ^[1-9][0-9]*$ && "$id" =~ ^[0-9]+$ ]] + install -d -m 700 "$destination" + curl --fail --silent --show-error --location --connect-timeout 15 --max-time 120 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$id/zip" \ + --output "$zip_path" + python3 - "$zip_path" "$destination" <<'PY' + import pathlib, stat, sys, zipfile + archive = pathlib.Path(sys.argv[1]) + destination = pathlib.Path(sys.argv[2]) + allowed = {"manifest.env", "controller.tar"} + with zipfile.ZipFile(archive) as bundle: + entries = bundle.infolist() + names = [entry.filename for entry in entries] + if len(names) != len(set(names)) or not names or not set(names).issubset(allowed): + raise SystemExit("invalid production artifact bundle") + if sum(entry.file_size for entry in entries) > 3 * 1024 * 1024: + raise SystemExit("production artifact bundle is too large") + for entry in entries: + path = pathlib.PurePosixPath(entry.filename) + mode = entry.external_attr >> 16 + if path.is_absolute() or ".." in path.parts or path.name != entry.filename: + raise SystemExit("unsafe production artifact path") + if mode and not stat.S_ISREG(mode): + raise SystemExit("unsafe production artifact type") + target = destination / entry.filename + with bundle.open(entry) as source, target.open("xb") as output: + output.write(source.read()) + PY + [[ -f "$destination/manifest.env" ]] + } + rm -rf artifacts/staging-image artifacts/controller + download_bundle "$TARGET_GATE_RUN_ID" "$DEPLOY_SHA" artifacts/staging-image "${RUNNER_TEMP}/staging-target.zip" + download_bundle "$CONTROLLER_GATE_RUN_ID" "$CONTROLLER_SHA" artifacts/controller "${RUNNER_TEMP}/production-controller.zip" + + - name: Validate gate-attested controller and immutable image manifest + id: images + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + CONTROLLER_SHA: ${{ steps.revision.outputs.controller_sha }} run: | set -euo pipefail - SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" - rsync -az --delete --exclude='.git/' --exclude='.env.production' --exclude='frontend/node_modules/' --exclude='frontend/.next/' -e "ssh $SSH_OPTIONS" ./ "$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/" - ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && GITHUB_SHA='$GITEA_SHA' docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build --remove-orphans" - - name: Verify production + controller_manifest=artifacts/controller/manifest.env + controller_tar=artifacts/controller/controller.tar + [[ -f "$controller_tar" ]] + [[ "$(wc -l < "$controller_manifest" | tr -d ' ')" == 4 ]] + manifest_controller_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$controller_manifest")" + expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$controller_manifest")" + [[ "$manifest_controller_sha" == "$CONTROLLER_SHA" ]] + [[ "$expected_controller_digest" =~ ^[0-9a-f]{64}$ ]] + printf '%s %s\n' "$expected_controller_digest" "$controller_tar" | sha256sum --check --status + python3 - "$controller_tar" <<'PY' + import pathlib, sys, tarfile + archive = pathlib.Path(sys.argv[1]) + required = {"deploy/run-production-deploy.sh", "frontend/scripts/staging-image-manifest.mjs"} + with tarfile.open(archive, "r:") as bundle: + members = bundle.getmembers() + names = [member.name for member in members] + if len(names) != len(set(names)) or not required.issubset(names): + raise SystemExit("invalid production controller bundle") + if sum(member.size for member in members) > 2 * 1024 * 1024: + raise SystemExit("production controller bundle is too large") + for member in members: + path = pathlib.PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile()): + raise SystemExit("unsafe production controller bundle") + PY + install -d -m 700 artifacts/controller/extracted + tar -xf "$controller_tar" -C artifacts/controller/extracted + node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \ + "$controller_manifest" "$CONTROLLER_SHA" "$IMAGE_REPOSITORY" >/dev/null + node artifacts/controller/extracted/frontend/scripts/staging-image-manifest.mjs \ + artifacts/staging-image/manifest.env "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT" + + - name: Deploy exact image digests under pinned SSH identity + env: + SSH_PRIVATE_KEY_BASE64: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + 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 }} + VERIFICATION_MODE: ${{ steps.revision.outputs.verification_mode }} run: | set -euo pipefail - curl -fsS --retry 12 --retry-delay 5 https://jyotisha.chat/login >/dev/null - test "$(curl -sS -o /dev/null -w '%{http_code}' https://jyotisha.chat/api/account)" = 401 - SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes" - ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" "cd '$DEPLOY_PATH' && docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec -T web node -e 'fetch(\"http://api:5200/api/health\").then(async r=>{const b=await r.json();if(!r.ok||b.status!==\"ok\"||b.swisseph_available!==true)process.exit(1)})'" + [[ "$DEPLOY_HOST" == "118.194.235.34" ]] + [[ "$DEPLOY_PORT" =~ ^[1-9][0-9]{0,4}$ ]] && (( DEPLOY_PORT <= 65535 )) + [[ "$DEPLOY_USER" == "deploy" ]] + [[ "$DEPLOY_PATH" == "/opt/jyotisha-production" ]] + [[ "$PRODUCTION_URL" == "https://jyotisha.chat" ]] + [[ "$PRODUCTION_ADMIN_URL" == "https://admin.jyotisha.chat" ]] + [[ "$VERIFICATION_MODE" == internal || "$VERIFICATION_MODE" == public ]] + ssh_root="${RUNNER_TEMP}/production-ssh" + key_path="$ssh_root/id_ed25519" + known_hosts_path="$ssh_root/known_hosts" + incoming="" + install -m 700 -d "$ssh_root" + test -n "$SSH_PRIVATE_KEY_BASE64" + printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode > "$key_path" + printf '%s\n' "$PRODUCTION_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" + chmod 600 "$key_path" "$known_hosts_path" + ssh-keygen -y -f "$key_path" >/dev/null + ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") + remote="$DEPLOY_USER@$DEPLOY_HOST" + require_current_release_heads() { + [[ "$ALLOW_ROLLBACK" == true ]] && return + current_staging="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/staging" | + jq -er 'select(type == "array" and length == 1) | .[0] | + select(.ref == "refs/heads/staging") | .object.sha | + select(test("^[0-9a-f]{40}$"))')" + current_main="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/main" | + jq -er 'select(type == "array" and length == 1) | .[0] | + select(.ref == "refs/heads/main") | .object.sha | + select(test("^[0-9a-f]{40}$"))')" + [[ "$current_main" == "$DEPLOY_SHA" && "$current_staging" == "$DEPLOY_SHA" ]] || { + echo "main or staging advanced during deployment; refusing stale mutation" >&2 + exit 1 + } + } + cleanup() { + if [[ -n "$incoming" ]]; then + ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; sudo -n rm -rf -- '$incoming'" >/dev/null 2>&1 || true + fi + rm -rf -- "$ssh_root" + } + trap cleanup EXIT + incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-production.XXXXXXXXXX")" + [[ "$incoming" == /tmp/jyotisha-production.* ]] + ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'" + scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/controller/controller.tar "$remote:$incoming/controller.tar" + ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/controller.tar' -C '$incoming' && rm -f -- '$incoming/controller.tar'" + previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; id=\$(sudo -n docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-production' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then sudo -n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; elif [ -e \"\$state\" ]; then printf state-present-without-container; else printf not-deployed; fi")" + [[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1 + forward_verified=false + if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" && "$ALLOW_ROLLBACK" != true ]]; then + comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")" + jq -e --arg base "$previous_sha" --arg head "$DEPLOY_SHA" ' + (.commits // []) as $commits | + def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha]; + def reaches($sha; $seen): + if $sha == $base then true + elif ($seen | index($sha)) != null then false + else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end; + (.total_commits | type) == "number" and + .total_commits == ($commits | length) and ($commits | length) > 0 and + ([$commits[].sha] | length == (unique | length)) and reaches($head; []) + ' <<<"$comparison" >/dev/null || { echo "automatic production rollback or divergent deploy refused" >&2; exit 1; } + forward_verified=true + fi + require_current_release_heads + printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh "${ssh_options[@]}" "$remote" "sudo -n env INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$API_IMAGE' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='$ALLOW_ROLLBACK' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' PRODUCTION_URL='$PRODUCTION_URL' PRODUCTION_ADMIN_URL='$PRODUCTION_ADMIN_URL' VERIFICATION_MODE='$VERIFICATION_MODE' bash '$incoming/deploy/run-production-deploy.sh'" + require_current_release_heads diff --git a/.gitea/workflows/migrate-production-database.yml b/.gitea/workflows/migrate-production-database.yml new file mode 100644 index 00000000..38cef122 --- /dev/null +++ b/.gitea/workflows/migrate-production-database.yml @@ -0,0 +1,363 @@ +name: Migrate Production Database (manual only) + +on: + workflow_dispatch: + inputs: + deploy_sha: + description: Full current production release SHA to migrate + required: true + type: string + recovery_reference: + description: Backup or PITR recovery reference; multiple migration files are not atomic as a set + required: true + type: string + recovery_created_at: + description: Recovery point creation time in UTC, exactly YYYY-MM-DDTHH:MM:SSZ and no more than 24 hours old + required: true + type: string + restore_verified: + description: Confirm that this recovery point has passed a restore verification + required: true + default: false + type: boolean + +permissions: + contents: read + actions: read + +concurrency: + group: production-mutation + cancel-in-progress: false + queue: max + +jobs: + migrate: + runs-on: manman-linux + timeout-minutes: 20 + env: + GITEA_SHA: ${{ gitea.sha }} + GITEA_API_URL: ${{ gitea.api_url }} + GITEA_REPOSITORY: ${{ gitea.repository }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com + IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha + DEPLOY_HOST: ${{ vars.PRODUCTION_HOST }} + DEPLOY_PORT: ${{ vars.PRODUCTION_PORT }} + DEPLOY_USER: ${{ vars.PRODUCTION_USER }} + DEPLOY_PATH: ${{ vars.PRODUCTION_PATH }} + STAGING_URL: ${{ vars.STAGING_URL }} + PRODUCTION_KNOWN_HOSTS: ${{ vars.PRODUCTION_KNOWN_HOSTS }} + steps: + - name: Validate current production revision and successful gates + id: revision + env: + DEPLOY_SHA: ${{ inputs.deploy_sha }} + RECOVERY_REFERENCE: ${{ inputs.recovery_reference }} + RECOVERY_CREATED_AT: ${{ inputs.recovery_created_at }} + RESTORE_VERIFIED: ${{ inputs.restore_verified }} + run: | + set -euo pipefail + [[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "deploy_sha must be a lowercase full commit SHA" >&2; exit 1; } + [[ "$STAGING_URL" == "https://staging.jyotisha.chat" ]] || { echo "unexpected staging acceptance URL" >&2; exit 1; } + [[ "$RECOVERY_REFERENCE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$ ]] || { + echo "recovery_reference must be 1-200 safe reference characters" >&2 + exit 1 + } + [[ "$RECOVERY_CREATED_AT" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || { + echo "recovery_created_at must be UTC in YYYY-MM-DDTHH:MM:SSZ format" >&2 + exit 1 + } + [[ "$RESTORE_VERIFIED" == "true" ]] || { + echo "restore_verified=true is required for a production schema migration" >&2 + exit 1 + } + python3 - "$RECOVERY_CREATED_AT" <<'PY' + from datetime import datetime, timedelta, timezone + import sys + + try: + created_at = datetime.strptime(sys.argv[1], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError as error: + raise SystemExit(f"invalid recovery_created_at: {error}") + age = datetime.now(timezone.utc) - created_at + if age < timedelta(0) or age > timedelta(hours=24): + raise SystemExit("recovery_created_at must be no more than 24 hours old and not in the future") + PY + echo "Recovery attested: reference=$RECOVERY_REFERENCE created_at=$RECOVERY_CREATED_AT restore_verified=true" + echo "WARNING: migration files run sequentially and are not atomic as a whole; recovery may be required after a partial migration." >&2 + read_ref_sha() { + local branch="$1" + curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/$branch" | + jq -er --arg ref "refs/heads/$branch" ' + select(type == "array" and length == 1) | + .[0] | select(.ref == $ref) | .object.sha | + select(test("^[0-9a-f]{40}$")) + ' + } + staging_head="$(read_ref_sha staging)" + main_head="$(read_ref_sha main)" + [[ "$main_head" == "$DEPLOY_SHA" && "$staging_head" == "$DEPLOY_SHA" ]] || { + echo "production migration requires main and staging to equal deploy_sha" >&2 + exit 1 + } + [[ "$GITEA_SHA" == "$DEPLOY_SHA" ]] || { + echo "dispatch the production migration workflow from the exact main release SHA" >&2 + exit 1 + } + runs="$(curl --fail --silent --show-error \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&branch=staging&event=push&status=success&limit=100")" + selected_run="$(jq -cer --arg sha "$DEPLOY_SHA" ' + [.workflow_runs[] | select( + (.path | split("@")[0] | endswith("backend-quality-gate.yml")) and + .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_id" =~ ^[0-9]+$ ]] || { + echo "no successful exact-SHA staging backend quality gate found" >&2 + exit 1 + } + release_runs="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs?head_sha=$DEPLOY_SHA&event=workflow_dispatch&status=success&limit=100")" + jq -e --arg sha "$DEPLOY_SHA" ' + any(.workflow_runs[]?; + (.path | split("@")[0] | endswith("release-quality-gate.yml")) and + .head_sha == $sha and .event == "workflow_dispatch" and .conclusion == "success" + ) + ' <<<"$release_runs" >/dev/null || { + echo "no successful exact-SHA manual release quality gate found" >&2 + exit 1 + } + observed_staging_sha="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 30 --retry 3 --retry-all-errors \ + "$STAGING_URL/api/health" | jq -er '.deployment.gitCommit | select(test("^[0-9a-f]{40}$"))')" + [[ "$observed_staging_sha" == "$DEPLOY_SHA" ]] || { + echo "public staging has not accepted the requested SHA" >&2 + exit 1 + } + { + echo "sha=$DEPLOY_SHA" + echo "gate_run_id=$gate_run_id" + echo "recovery_reference=$RECOVERY_REFERENCE" + echo "recovery_created_at=$RECOVERY_CREATED_AT" + echo "restore_verified=true" + } >>"$GITHUB_OUTPUT" + + - name: Prepare pinned Node tooling + env: + NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c + NODE_TOOL_IMAGE: node:22-bookworm-slim + run: | + set -euo pipefail + if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then + for attempt in 1 2 3; do + if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2 + exit 1 + fi + sleep $((attempt * 15)) + done + fi + docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE" + docker image inspect "$NODE_TOOL_IMAGE" >/dev/null + tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")" + cat > "$tool_dir/node" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + workdir="$(pwd -P)" + exec docker run --rm \ + --user "$(id -u):$(id -g)" \ + --volume "$workdir:$workdir" \ + --workdir "$workdir" \ + --env HOME=/tmp \ + node:22-bookworm-slim "${0##*/}" "$@" + EOF + chmod 0755 "$tool_dir/node" + ln -s node "$tool_dir/npm" + test -n "${GITHUB_PATH:-}" + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + export PATH="$tool_dir:$PATH" + node --version + npm --version + + - name: Download gate-produced migration manifest + env: + GATE_RUN_ID: ${{ steps.revision.outputs.gate_run_id }} + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + artifact_prefix="staging-image-manifest-$DEPLOY_SHA-" + artifacts="$(curl --fail --silent --show-error \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/runs/$GATE_RUN_ID/artifacts?limit=100")" + selected_artifact="$(jq -cer --arg prefix "$artifact_prefix" ' + [(.artifacts // [])[] + | select(.expired == false and (.name | startswith($prefix))) + | . + {attempt: ((.name | ltrimstr($prefix)) | tonumber?)} + | select(.attempt != null and .attempt >= 1) + ] | sort_by(.attempt, .id) | reverse | first + ' <<<"$artifacts")" + artifact_name="$(jq -er '.name' <<<"$selected_artifact")" + artifact_id="$(jq -er '.id' <<<"$selected_artifact")" + artifact_attempt="${artifact_name#"$artifact_prefix"}" + [[ "$artifact_name" == "$artifact_prefix"* ]] + [[ "$artifact_attempt" =~ ^[1-9][0-9]*$ ]] + [[ "$artifact_id" =~ ^[0-9]+$ ]] + install -d -m 700 artifacts/staging-image + curl --fail --silent --show-error --location \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/actions/artifacts/$artifact_id/zip" \ + --output "${RUNNER_TEMP}/staging-image-manifest.zip" + python3 - "${RUNNER_TEMP}/staging-image-manifest.zip" artifacts/staging-image <<'PY' + import pathlib, stat, sys, zipfile + archive = pathlib.Path(sys.argv[1]) + destination = pathlib.Path(sys.argv[2]) + allowed = {"manifest.env", "controller.tar"} + with zipfile.ZipFile(archive) as bundle: + entries = bundle.infolist() + names = [entry.filename for entry in entries] + if len(names) != len(set(names)) or set(names) != allowed: + raise SystemExit("invalid production migration artifact bundle") + if sum(entry.file_size for entry in entries) > 3 * 1024 * 1024: + raise SystemExit("production migration artifact bundle is too large") + for entry in entries: + path = pathlib.PurePosixPath(entry.filename) + mode = entry.external_attr >> 16 + if path.is_absolute() or ".." in path.parts or path.name != entry.filename: + raise SystemExit("unsafe production migration artifact path") + if mode and not stat.S_ISREG(mode): + raise SystemExit("unsafe production migration artifact type") + target = destination / entry.filename + with bundle.open(entry) as source, target.open("xb") as output: + output.write(source.read()) + PY + [[ -f artifacts/staging-image/manifest.env ]] + [[ -f artifacts/staging-image/controller.tar ]] + + - name: Validate gate-attested controller and digest-pinned migration image + id: image + env: + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + run: | + set -euo pipefail + manifest=artifacts/staging-image/manifest.env + controller_tar=artifacts/staging-image/controller.tar + [[ "$(wc -l < "$manifest" | tr -d ' ')" == 4 ]] + manifest_sha="$(awk -F= '$1 == "git_sha" {print $2}' "$manifest")" + expected_controller_digest="$(awk -F= '$1 == "controller_sha256" {print $2}' "$manifest")" + [[ "$manifest_sha" == "$DEPLOY_SHA" && "$expected_controller_digest" =~ ^[0-9a-f]{64}$ ]] + printf '%s %s\n' "$expected_controller_digest" "$controller_tar" | sha256sum --check --status + python3 - "$controller_tar" <<'PY' + import pathlib, sys, tarfile + archive = pathlib.Path(sys.argv[1]) + required = {"deploy/run-production-migration.sh", "frontend/scripts/staging-image-manifest.mjs"} + with tarfile.open(archive, "r:") as bundle: + members = bundle.getmembers() + names = [member.name for member in members] + if len(names) != len(set(names)) or not required.issubset(names): + raise SystemExit("invalid production migration controller bundle") + if sum(member.size for member in members) > 2 * 1024 * 1024: + raise SystemExit("production migration controller bundle is too large") + for member in members: + path = pathlib.PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts or not (member.isdir() or member.isfile()): + raise SystemExit("unsafe production migration controller bundle") + PY + install -d -m 700 artifacts/staging-image/extracted + tar -xf "$controller_tar" -C artifacts/staging-image/extracted + node artifacts/staging-image/extracted/frontend/scripts/staging-image-manifest.mjs \ + "$manifest" "$DEPLOY_SHA" "$IMAGE_REPOSITORY" >>"$GITHUB_OUTPUT" + + - name: Apply production schema migration under pinned SSH identity + env: + SSH_PRIVATE_KEY_BASE64: ${{ secrets.PRODUCTION_SSH_PRIVATE_KEY }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + DEPLOY_SHA: ${{ steps.revision.outputs.sha }} + WEB_IMAGE: ${{ steps.image.outputs.web_image }} + RECOVERY_REFERENCE: ${{ steps.revision.outputs.recovery_reference }} + RECOVERY_CREATED_AT: ${{ steps.revision.outputs.recovery_created_at }} + RESTORE_VERIFIED: ${{ steps.revision.outputs.restore_verified }} + run: | + set -euo pipefail + [[ "$DEPLOY_HOST" == "118.194.235.34" ]] + [[ "$DEPLOY_PORT" =~ ^[1-9][0-9]{0,4}$ ]] && (( DEPLOY_PORT <= 65535 )) + [[ "$DEPLOY_USER" == "deploy" ]] + [[ "$DEPLOY_PATH" == "/opt/jyotisha-production" ]] + test -n "$PRODUCTION_KNOWN_HOSTS" + ssh_root="${RUNNER_TEMP}/production-migration-ssh" + key_path="$ssh_root/id_ed25519" + known_hosts_path="$ssh_root/known_hosts" + incoming="" + install -m 700 -d "$ssh_root" + test -n "$SSH_PRIVATE_KEY_BASE64" + printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode > "$key_path" + printf '%s\n' "$PRODUCTION_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" + chmod 600 "$key_path" "$known_hosts_path" + ssh-keygen -y -f "$key_path" >/dev/null + ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") + remote="$DEPLOY_USER@$DEPLOY_HOST" + require_current_release_heads() { + current_staging="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/staging" | + jq -er 'select(type == "array" and length == 1) | .[0] | + select(.ref == "refs/heads/staging") | .object.sha | + select(test("^[0-9a-f]{40}$"))')" + current_main="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/git/refs/heads/main" | + jq -er 'select(type == "array" and length == 1) | .[0] | + select(.ref == "refs/heads/main") | .object.sha | + select(test("^[0-9a-f]{40}$"))')" + [[ "$current_main" == "$DEPLOY_SHA" && "$current_staging" == "$DEPLOY_SHA" ]] || { + echo "main or staging advanced during production migration; refusing stale mutation" >&2 + exit 1 + } + } + cleanup() { + if [[ -n "$incoming" ]]; then + ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; sudo -n rm -rf -- '$incoming'" >/dev/null 2>&1 || true + fi + rm -rf -- "$ssh_root" + } + trap cleanup EXIT + incoming="$(ssh "${ssh_options[@]}" "$remote" "mktemp -d /tmp/jyotisha-production-migration.XXXXXXXXXX")" + [[ "$incoming" == /tmp/jyotisha-production-migration.* ]] + ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'" + scp -i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path" artifacts/staging-image/controller.tar "$remote:$incoming/controller.tar" + ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/controller.tar' -C '$incoming' && rm -f -- '$incoming/controller.tar'" + previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; id=\$(sudo -n docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-production' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then sudo -n docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1; elif [ -e \"\$state\" ]; then printf state-present-without-container; else printf not-deployed; fi")" + [[ "$previous_sha" == not-deployed || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || exit 1 + forward_verified=false + if [[ "$previous_sha" != not-deployed && "$previous_sha" != "$DEPLOY_SHA" ]]; then + comparison="$(curl --fail --silent --show-error --connect-timeout 15 --max-time 60 --retry 3 --retry-all-errors \ + --header "Authorization: token $GITEA_TOKEN" \ + "$GITEA_API_URL/repos/$GITEA_REPOSITORY/compare/$previous_sha...$DEPLOY_SHA")" + jq -e --arg base "$previous_sha" --arg head "$DEPLOY_SHA" ' + (.commits // []) as $commits | + def parents($sha): [$commits[] | select(.sha == $sha) | (.parents // [])[] | .sha]; + def reaches($sha; $seen): + if $sha == $base then true + elif ($seen | index($sha)) != null then false + else any(parents($sha)[]; . as $parent | reaches($parent; $seen + [$sha])) end; + (.total_commits | type) == "number" and + .total_commits == ($commits | length) and ($commits | length) > 0 and + ([$commits[].sha] | length == (unique | length)) and reaches($head; []) + ' <<<"$comparison" >/dev/null || { echo "production migration rollback or divergence refused" >&2; exit 1; } + forward_verified=true + fi + require_current_release_heads + printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "sudo -n docker --config '$incoming/.docker' login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh "${ssh_options[@]}" "$remote" "sudo -n env INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' WEB_IMAGE='$WEB_IMAGE' DEPLOY_SHA='$DEPLOY_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' FORWARD_REVISION_VERIFIED='$forward_verified' RECOVERY_REFERENCE='$RECOVERY_REFERENCE' RECOVERY_CREATED_AT='$RECOVERY_CREATED_AT' RESTORE_VERIFIED='$RESTORE_VERIFIED' DOCKER_CONFIG='$incoming/.docker' DOCKER_BIN='docker' bash '$incoming/deploy/run-production-migration.sh'" + require_current_release_heads + + - name: Operator action + run: echo 'Schema migration complete. Production ETL and application deployment remain separate manual operations.' diff --git a/.gitea/workflows/release-quality-gate.yml b/.gitea/workflows/release-quality-gate.yml index b235097f..37506b1c 100644 --- a/.gitea/workflows/release-quality-gate.yml +++ b/.gitea/workflows/release-quality-gate.yml @@ -5,10 +5,12 @@ on: jobs: release-quality-gate: - runs-on: xiaoxin + runs-on: manman-linux timeout-minutes: 45 env: GITEA_SHA: ${{ gitea.sha }} + NODE_TOOL_SOURCE_IMAGE: swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-bookworm-slim@sha256:ef343465b6a14bbdf2ab52f6e100ec0659a792464fcf72c462370d88b3df909c + NODE_TOOL_IMAGE: node:22-bookworm-slim steps: - name: Checkout current Gitea revision run: | @@ -18,6 +20,37 @@ jobs: git remote add origin https://git.copse.top/root/Jyotisha.git git fetch --no-tags origin "$GITEA_SHA" git checkout --detach --force "$GITEA_SHA" + - name: Prepare pinned Node tooling + run: | + set -euo pipefail + if ! docker image inspect "$NODE_TOOL_SOURCE_IMAGE" >/dev/null 2>&1; then + for attempt in 1 2 3; do + if timeout 180 docker pull "$NODE_TOOL_SOURCE_IMAGE"; then + break + fi + if [ "$attempt" -eq 3 ]; then + echo "Failed to preload $NODE_TOOL_IMAGE after $attempt attempts" >&2 + exit 1 + fi + sleep $((attempt * 15)) + done + fi + docker tag "$NODE_TOOL_SOURCE_IMAGE" "$NODE_TOOL_IMAGE" + docker image inspect "$NODE_TOOL_IMAGE" >/dev/null + tool_dir="$(mktemp -d "${RUNNER_TEMP:-/tmp}/jyotisha-node-tools.XXXXXX")" + container_id="$(docker create "$NODE_TOOL_IMAGE")" + trap 'docker rm -f "$container_id" >/dev/null 2>&1 || true' EXIT + docker cp "$container_id:/usr/local/bin/node" "$tool_dir/node" + docker cp "$container_id:/usr/local/lib/node_modules/npm" "$tool_dir/npm-package" + docker rm "$container_id" >/dev/null + trap - EXIT + ln -s "$tool_dir/npm-package/bin/npm-cli.js" "$tool_dir/npm" + chmod 0755 "$tool_dir/node" "$tool_dir/npm-package/bin/npm-cli.js" + test -n "${GITHUB_PATH:-}" + printf '%s\n' "$tool_dir" >> "$GITHUB_PATH" + export PATH="$tool_dir:$PATH" + node --version | grep -Eq '^v22\.' + npm --version - name: Verify runner toolchain run: | set -euo pipefail @@ -25,16 +58,25 @@ jobs: node --version npm --version docker version + docker compose version --short | grep -Eq '^v?2\.' - name: Install dependencies and run release gate env: NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder + PIP_INDEX_URL: https://mirrors.aliyun.com/pypi/simple/ + NPM_CONFIG_REGISTRY: https://registry.npmmirror.com run: | set -euo pipefail python3 -m venv .venv export PATH="$PWD/.venv/bin:$PATH" python -m pip install --upgrade pip - python -m pip install -r requirements.txt -r requirements-dev.txt playwright + python -m pip install \ + "mcp==1.28.1" \ + "pydantic==2.13.4" \ + "numpy==2.5.1" \ + "pandas==2.3.3" \ + "timezonefinder==8.2.5" \ + -r requirements.txt -r requirements-dev.txt playwright python -m playwright install --with-deps chromium npm ci --prefix frontend python scripts/run_quality_gate.py --profile release diff --git a/.github/workflows/configure-staging-rectification-rollout.yml b/.github/workflows/configure-staging-rectification-rollout.yml deleted file mode 100644 index d461c27c..00000000 --- a/.github/workflows/configure-staging-rectification-rollout.yml +++ /dev/null @@ -1,93 +0,0 @@ -name: Configure Staging Rectification Rollout - -on: - workflow_dispatch: - inputs: - expected_deploy_sha: - description: Exact 40-character SHA currently deployed to staging - required: true - type: string - audience: - description: New-case creation audience - required: true - default: paused - type: choice - options: - - paused - - smoke_only - - public - synthetic_smoke_user_ids: - description: Comma-separated canonical UUIDs; required only for smoke_only - required: false - type: string - -permissions: - contents: read - -concurrency: - group: staging-mutation - cancel-in-progress: false - -jobs: - configure: - runs-on: ubuntu-latest - timeout-minutes: 10 - 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 }} - EXPECTED_DEPLOY_SHA: ${{ inputs.expected_deploy_sha }} - ROLLOUT_AUDIENCE: ${{ inputs.audience }} - SYNTHETIC_SMOKE_USER_IDS: ${{ inputs.synthetic_smoke_user_ids }} - - steps: - - name: Checkout trusted controller - uses: actions/checkout@v4 - with: - ref: main - persist-credentials: false - - - name: Validate rollout request and staging target - run: | - set -euo pipefail - [[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] - case "$ROLLOUT_AUDIENCE" in paused|smoke_only|public) ;; *) exit 1 ;; esac - if [ "$ROLLOUT_AUDIENCE" = smoke_only ]; then - [[ "$SYNTHETIC_SMOKE_USER_IDS" =~ ^[0-9a-f-]{36}(,[0-9a-f-]{36})*$ ]] - else - test -z "$SYNTHETIC_SMOKE_USER_IDS" - fi - 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" - bash -n deploy/configure-staging-rectification-rollout.sh - - - name: Configure pinned staging SSH - env: - SSH_PRIVATE_KEY_BASE64: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} - run: | - set -euo pipefail - test -n "$SSH_PRIVATE_KEY_BASE64" - install -d -m 700 ~/.ssh - printf '%s' "$SSH_PRIVATE_KEY_BASE64" | base64 --decode >~/.ssh/jyotisha-staging - chmod 600 ~/.ssh/jyotisha-staging - ssh-keygen -y -f ~/.ssh/jyotisha-staging >/dev/null - printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts - chmod 600 ~/.ssh/known_hosts - - - name: Apply rollout under staging mutation lock - 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=10" - ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "DEPLOY_PATH='$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='$EXPECTED_DEPLOY_SHA' ROLLOUT_AUDIENCE='$ROLLOUT_AUDIENCE' SYNTHETIC_SMOKE_USER_IDS='$SYNTHETIC_SMOKE_USER_IDS' STAGING_URL='$STAGING_URL' bash -s" \ - < deploy/configure-staging-rectification-rollout.sh diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 03f5fd70..5da0a860 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -1,4 +1,4 @@ -name: Deploy production +name: Production deployment moved to Gitea on: workflow_dispatch: @@ -6,193 +6,11 @@ on: permissions: contents: read -concurrency: - group: production - cancel-in-progress: false - -env: - DEPLOY_HOST: 103.117.123.53 - DEPLOY_PORT: "22000" - DEPLOY_USER: root - DEPLOY_PATH: /opt/jyotisha-app - jobs: - deploy: + retired: runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout tested revision - uses: actions/checkout@v4 - with: - ref: ${{ github.sha }} - - - name: Reject stale CI revision - id: revision + - name: Refuse deployment from the mirror 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: | - install -m 700 -d ~/.ssh - printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/jyotisha-production - chmod 600 ~/.ssh/jyotisha-production - printf '%s\n' '[103.117.123.53]:22000 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIHQJvN2Mo3Yq8e6ZIK4P2blJ5Vjj0HbknEuk7TyjhMbO' > ~/.ssh/known_hosts - - - name: Sync reviewed production environment keys - if: steps.revision.outputs.deploy == 'true' - env: - GEOAPIFY_API_KEY: ${{ secrets.GEOAPIFY_API_KEY }} - VEDASTRO_API_KEY: ${{ secrets.VEDASTRO_API_KEY }} - VEDASTRO_API_ENDPOINT: ${{ secrets.VEDASTRO_API_ENDPOINT }} - VEDASTRO_ENABLE_NETWORK: ${{ secrets.VEDASTRO_ENABLE_NETWORK }} - VEDASTRO_GATEWAY_MODE: ${{ secrets.VEDASTRO_GATEWAY_MODE }} - VEDASTRO_RANGE_SCAN_NETWORK_ENABLED: ${{ secrets.VEDASTRO_RANGE_SCAN_NETWORK_ENABLED }} - VEDASTRO_TIMEOUT_SECONDS: ${{ secrets.VEDASTRO_TIMEOUT_SECONDS }} - JYOTISH_DYNAMIC_RECTIFICATION_TOKEN: ${{ secrets.JYOTISH_DYNAMIC_RECTIFICATION_TOKEN }} - run: | - set -euo pipefail - set +x - UPDATE_FILE="$RUNNER_TEMP/production-env-update.json" - REMOTE_UPDATE_FILE="$DEPLOY_PATH/.env.production.update.$GITHUB_RUN_ID" - export UPDATE_FILE - umask 077 - python3 - <<'PY' - import json - import os - from pathlib import Path - - keys = ( - "GEOAPIFY_API_KEY", - "VEDASTRO_API_KEY", - "VEDASTRO_API_ENDPOINT", - "VEDASTRO_ENABLE_NETWORK", - "VEDASTRO_GATEWAY_MODE", - "VEDASTRO_RANGE_SCAN_NETWORK_ENABLED", - "VEDASTRO_TIMEOUT_SECONDS", - "JYOTISH_DYNAMIC_RECTIFICATION_TOKEN", - ) - values = {key: os.environ.get(key, "") for key in keys} - missing = [key for key, value in values.items() if not value] - if missing: - raise SystemExit("required production environment secret is missing") - Path(os.environ["UPDATE_FILE"]).write_text( - json.dumps(values, ensure_ascii=False), - encoding="utf-8", - ) - PY - SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" - SCP_OPTIONS="-i $HOME/.ssh/jyotisha-production -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" - scp $SCP_OPTIONS "$UPDATE_FILE" "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_UPDATE_FILE" - ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ - "cd '$DEPLOY_PATH' && python3 - '$REMOTE_UPDATE_FILE'" <<'PY' - import json - import os - import re - import sys - import tempfile - from pathlib import Path - - env_path = Path(".env.production") - update_path = Path(sys.argv[1]) - try: - if not env_path.is_file(): - raise SystemExit("production environment file is missing") - updates = json.loads(update_path.read_text(encoding="utf-8")) - if not isinstance(updates, dict) or not updates: - raise SystemExit("production environment update is empty") - for key, value in updates.items(): - if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key): - raise SystemExit("production environment key is invalid") - if not isinstance(value, str) or not value or "\n" in value or "\0" in value: - raise SystemExit("production environment value is invalid") - - original = env_path.read_text(encoding="utf-8").splitlines() - output = [] - written = set() - assignment = re.compile(r"^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=") - - def quote(value): - return "'" + value.replace("'", "'\"'\"'") + "'" - - for line in original: - match = assignment.match(line) - key = match.group(1) if match else None - if key not in updates: - output.append(line) - continue - if key not in written: - output.append(f"{key}={quote(updates[key])}") - written.add(key) - for key, value in updates.items(): - if key not in written: - output.append(f"{key}={quote(value)}") - - fd, temporary_name = tempfile.mkstemp( - prefix=".env.production.", - dir=str(env_path.parent), - text=True, - ) - try: - with os.fdopen(fd, "w", encoding="utf-8") as handle: - handle.write("\n".join(output) + "\n") - handle.flush() - os.fsync(handle.fileno()) - os.chmod(temporary_name, 0o600) - os.replace(temporary_name, env_path) - finally: - if os.path.exists(temporary_name): - os.unlink(temporary_name) - print(f"updated {len(updates)} production environment keys") - finally: - update_path.unlink(missing_ok=True) - PY - rm -f "$UPDATE_FILE" - - - name: Sync and rebuild - if: steps.revision.outputs.deploy == 'true' - env: - DEPLOY_GIT_SHA: ${{ github.sha }} - run: | - SSH_OPTIONS="-i $HOME/.ssh/jyotisha-production -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=20" - RSYNC_SSH="ssh $SSH_OPTIONS" - rsync -az --delete \ - --exclude='.git/' \ - --exclude='.env.production' \ - --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' && 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.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" \ - "cd '$DEPLOY_PATH' && docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec -T web node -e 'fetch(\"http://api:5200/api/health\").then(async r => { const body = await r.json(); if (!r.ok || body.status !== \"ok\" || body.swisseph_available !== true) process.exit(1); console.log(JSON.stringify(body)); })'" + echo "Production deployment is controlled by .gitea/workflows/deploy-production.yml in git.copse.top." >&2 + exit 1 diff --git a/AGENTS.md b/AGENTS.md index 8b463b81..3aece1fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,21 +8,22 @@ - Production domain: `https://jyotisha.chat` - Primary source: `https://git.copse.top/root/Jyotisha.git`; GitHub upstream/mirror: `https://github.com/jesse-ux/Jyotisha.git` -- Server: Hong Kong Ubuntu 22.04 VPS, `103.117.123.53`, SSH port `22000` -- Runtime: `/opt/jyotisha-app`, Docker Compose file `deploy/docker-compose.server.yml` -- Secrets: `/opt/jyotisha-app/.env.production`; never print, copy into chat, or commit +- Migration target: Ubuntu VPS `118.194.235.34`; confirmed SSH port is a required deployment variable +- Target runtime: `/opt/jyotisha-production`, Compose project `jyotisha-production` +- Target secrets: `/opt/jyotisha-production/.env.production` and `.env.production.database`; never print, copy into chat, or commit - Public edge: Caddy only; Next.js `3000` and Python API `5200` stay Docker-private -- Managed services: Spaceship DNS, Supabase project `vtvnfqmonbfuxmqkqdlc`, external model API -- Capacity boundary: 1 vCPU / 2 GB RAM / 40 GB disk / 5 Mbps; demo and low concurrency only +- Migration source: Spaceship DNS and Supabase project `vtvnfqmonbfuxmqkqdlc`; production target uses private PostgreSQL + Better Auth +- Capacity boundary: 2 vCPU / 4 GB RAM; use digest-pinned images, bounded DB pools, and no on-host application builds +- Cutover runbook: `docs/operations/production-server-migration-2026-08.md`; old VPS/Supabase remain rollback assets until final reconciliation Deployment safety rules: 1. Run `git status --short --branch` before packaging; do not overwrite unrelated dirty files. -2. Verify `dig +short @launch1.spaceship.net A jyotisha.chat` returns `103.117.123.53` before troubleshooting Caddy certificate issuance. -3. Keep Supabase Auth Site URL and redirect URLs aligned with `https://jyotisha.chat`. +2. Before cutover, verify the authoritative DNS still matches the documented current phase; after cutover both user and admin hosts must resolve to `118.194.235.34`. +3. The target uses Better Auth with exact user/admin origins; migrated users must sign in again by OTP. 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. 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`. +6. Production deployment is manual-only in Gitea. It consumes the exact staging-gate image digests, requires matching `main`/`staging`/public-staging SHA plus the manual release gate, and never changes DNS or imports production data. GitHub production deployment is retired. ## 1. High-Rigor Override diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index ff80c204..be70e398 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -19,6 +19,7 @@ RESEND_API_KEY= RESEND_FROM_EMAIL=Jyotisha Staging ADMIN_EMAILS= EPAY_CONFIG_ENCRYPTION_KEY= +MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY= EPAY_CHAT_ENABLED=false JYOTISH_DYNAMIC_RECTIFICATION_TOKEN= diff --git a/deploy/Caddyfile.production.selfhosted b/deploy/Caddyfile.production.selfhosted new file mode 100644 index 00000000..a03e91b5 --- /dev/null +++ b/deploy/Caddyfile.production.selfhosted @@ -0,0 +1,19 @@ +{$SITE_ADDRESS:https://jyotisha.chat} { + encode zstd gzip + + @adminPaths path /admin /admin/* /api/admin/* + respond @adminPaths "Not found" 404 + + reverse_proxy web:3000 +} + +https://admin.jyotisha.chat { + encode zstd gzip + @root path / + redir @root /admin 308 + reverse_proxy web:3000 +} + +https://www.jyotisha.chat { + redir https://jyotisha.chat{uri} 308 +} diff --git a/deploy/README.md b/deploy/README.md index 0c24971b..025365d8 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -1,77 +1,78 @@ # Production deployment and maintenance -This file is the operational source of truth for the current Jyotisha demo deployment. +This file is the operational source of truth for Jyotisha deployment. The production migration is governed by `docs/operations/production-server-migration-2026-08.md`; do not change DNS or retire the old environment outside that runbook. -## Current production +## Production migration state | Item | Value | | --- | --- | | Public domain | `https://jyotisha.chat` | | DNS | Spaceship nameservers (`launch1.spaceship.net`, `launch2.spaceship.net`) | -| Server | Hong Kong VPS, Ubuntu 22.04 x86_64 | -| Public host | `103.117.123.53` | -| SSH | port `22000`, public-key authentication only | -| Capacity | 1 vCPU / 2 GB RAM / 40 GB disk / 5 Mbps | -| App directory | `/opt/jyotisha-app` | -| Environment file | `/opt/jyotisha-app/.env.production` (`0600`) | +| Current public host | Old VPS; keep as a rollback asset until reconciliation completes | +| Target host | `118.194.235.34`, Ubuntu x86_64 | +| Target SSH | dedicated `deploy` user, confirmed variable port, public-key authentication only | +| Target capacity | 2 vCPU / 4 GB RAM; no application builds on host | +| Target app directory | `/opt/jyotisha-production` | +| Target environment files | `.env.production` and `.env.production.database` (`0600`) | | Primary source repository | `https://git.copse.top/root/Jyotisha.git` | | GitHub upstream/mirror | `https://github.com/jesse-ux/Jyotisha.git` | -| Supabase project | `vtvnfqmonbfuxmqkqdlc` | +| Migration source | Supabase project `vtvnfqmonbfuxmqkqdlc` + Supabase Auth | +| Migration target | private PostgreSQL 17 + Better Auth | -This machine is suitable for a client demo and low concurrency. Supabase and the model provider stay managed externally; do not self-host them on this VPS. +This migration changes both infrastructure and persistence. It is a controlled Supabase-to-private-PostgreSQL ETL, not a database-volume copy or full-dump restore. Keep the old VPS for 7–14 days and Supabase for 14–30 days after cutover. ## Architecture ```text -Spaceship DNS - -> Caddy :80/:443 - -> web:3000 (Next.js + Mastra, Docker-private) - -> api:5200 (Python Jyotish API, Docker-private) - -> Swiss Ephemeris / local engine - -> VedAstro gateway with local fallback - -> Supabase Cloud - -> external OpenAI-compatible model API +Spaceship DNS -> Caddy :80/:443 -> web:3000 -> api:5200 + | -> local astrology engines + -> private PostgreSQL 17 + Better Auth + -> external model and mail providers ``` Only Caddy publishes host ports. Ports `3000` and `5200` must remain private. ## DNS and Supabase Auth -Spaceship resource records: +Final Spaceship resource records (apply only during the approved cutover window): ```text -A @ 103.117.123.53 +A @ 118.194.235.34 +A admin 118.194.235.34 CNAME www jyotisha.chat ``` -Supabase Authentication URL Configuration: - -```text -Site URL: https://jyotisha.chat -Redirect URLs: https://jyotisha.chat/** - https://www.jyotisha.chat/** -``` - -Before changing Caddy to the domain, verify the authoritative DNS result: +After cutover, verify both authoritative nameservers: ```bash dig +short @launch1.spaceship.net A jyotisha.chat +dig +short @launch2.spaceship.net A admin.jyotisha.chat ``` -It must return `103.117.123.53`. Caddy provisions and renews HTTPS automatically after DNS resolves. +Both user and admin hosts must return `118.194.235.34`. Caddy provisions and renews HTTPS automatically after DNS resolves. ## Production environment -`.env.production` combines the backend and frontend server variables. Required groups: +`.env.production` contains runtime-only application settings. Database bootstrap, migration, and backup credentials belong only in `.env.production.database`. ```dotenv SITE_ADDRESS=https://jyotisha.chat +APP_ENV_FILE=../.env.production +CADDYFILE_PATH=./Caddyfile.production.selfhosted JYOTISH_API_BASE=http://api:5200 GEOAPIFY_API_KEY= -NEXT_PUBLIC_SUPABASE_URL=... -NEXT_PUBLIC_SUPABASE_ANON_KEY=... -SUPABASE_SERVICE_ROLE_KEY=... +AUTH_PROVIDER=self-hosted +SELF_HOSTED_IDENTITY_ENABLED=true +AUTH_USER_ORIGIN=https://jyotisha.chat +ADMIN_USER_ORIGIN=https://admin.jyotisha.chat +IDENTITY_DATABASE_URL=postgresql://identity_runtime:@postgres:5432/jyotisha +APP_DATABASE_URL=postgresql://app_runtime:@postgres:5432/jyotisha +SERVICE_DATABASE_URL=postgresql://service_runtime:@postgres:5432/jyotisha +ADMIN_DATABASE_URL=postgresql://admin_runtime:@postgres:5432/jyotisha +BETTER_AUTH_USER_SECRET= +RESEND_API_KEY= +RESEND_FROM_EMAIL= ADMIN_EMAILS=... # Required to save/read database-backed 易支付 settings. Base64 decoding must @@ -82,30 +83,15 @@ EPAY_CONFIG_ENCRYPTION_KEY= # Online packages stay hidden by default; only explicit true enables the fallback. EPAY_CHAT_ENABLED=false -# Conversational birth-time rectification rollout controls. -# Keep migrations false until the ordered database gate below has passed. +# Fixed birth-time rectification fee shown by the account UI. RECTIFICATION_PRICE_CREDITS=3 -RECTIFICATION_V3_CREATE_ENABLED=true -RECTIFICATION_V3_MIGRATIONS_READY=false -# Set only after the authenticated synthetic smoke passes on this exact image. -RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA= -# During canary only: one canonical synthetic account UUID. Never print or log it. -RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS= -# Recommended multi-model catalog. The JSON references server-only keys. -LLM_DEFAULT_MODEL_ID=deepseek-pro -LLM_MODELS_JSON='[{"id":"deepseek-pro","label":"DeepSeek V4 Pro","description":"更适合复杂分析","provider":"openai-compatible","baseURL":"https://api.deepseek.com","apiKeyEnv":"DEEPSEEK_API_KEY","model":"deepseek-v4-pro","creditCost":1},{"id":"gpt-5-mini","label":"ChatGPT 5 Mini","description":"响应稳定、速度均衡","provider":"openai","apiKeyEnv":"OPENAI_API_KEY","model":"openai/gpt-5-mini","creditCost":1}]' -DEEPSEEK_API_KEY= -OPENAI_API_KEY= +# Required to encrypt/decrypt model-provider API keys stored in the admin database. +# Base64 decoding must produce exactly 32 random bytes; do not reuse other keys. +MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY= -# Legacy single-model OpenAI configuration remains supported: -# OPENAI_API_KEY= -# MASTRA_MODEL=openai/gpt-5-mini - -# Legacy single OpenAI-compatible provider remains supported: -# LLM_BASE_URL=https://provider.example/v1 -# LLM_API_KEY= -# LLM_MODEL=provider-model-id +# The admin database model catalog is the only runtime model/provider source. +# Provider API keys are entered in the admin UI and are never read from provider env vars. # Required VedAstro server-side upstream for chart creation and rectification: VEDASTRO_GATEWAY_MODE=official_first @@ -117,47 +103,37 @@ VEDASTRO_TIMEOUT_SECONDS=20 VEDASTRO_API_KEY= ``` -Never commit `.env.production`, `SUPABASE_SERVICE_ROLE_KEY`, model keys, user JWTs, SSH private keys or passwords. `NEXT_PUBLIC_SUPABASE_ANON_KEY` is intentionally public; authorization is enforced by Supabase RLS and server-side checks. +Never commit either production env file, model/payment keys, user tokens, SSH private keys, database URLs, or passwords. Do not retain Supabase runtime selectors in the target env; Supabase is migration source/rollback storage only. -After changing VedAstro variables, restart the API and verify the configuration without printing credentials: -```bash -docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build api -docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec api python3 scripts/diagnose_vedastro_mode.py -``` -The report must show `mode: official_extended` and `network_enabled: true`. A missing raw response remains an upstream response boundary, not a successful external verification. +After changing runtime variables, use the exact-SHA Gitea workflow to recreate services; do not rebuild or rsync a local tree on the host. ## Connect and inspect ```bash -ssh -p 22000 root@103.117.123.53 -cd /opt/jyotisha-app -COMPOSE='docker compose --env-file .env.production -f deploy/docker-compose.server.yml' +ssh -p deploy@118.194.235.34 +cd /opt/jyotisha-production +COMPOSE='docker compose -p jyotisha-production --env-file .env.production -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml -f deploy/docker-compose.production.yml' $COMPOSE ps -$COMPOSE logs --tail=100 api web caddy +$COMPOSE logs --tail=100 postgres api web caddy free -h docker stats --no-stream ``` -The server has a persistent 2 GB `/swapfile`. UFW permits only SSH `22000/tcp`, HTTP `80/tcp`, HTTPS `443/tcp`, and the pre-existing WireGuard `51820/udp` rule. +UFW permits only the confirmed SSH port, HTTP, and HTTPS. PostgreSQL, Web, API, and the Docker API remain private. -## Manual deployment with GitHub Actions +## Manual production deployment with Gitea Actions -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. +GitHub production deployment is retired. Production changes are released only by manually dispatching `.gitea/workflows/deploy-production.yml`. The workflow requires an exact SHA shared by `main` and `staging`, an exact-SHA staging push gate and image manifest, the manual release gate, and matching public staging health. It deploys immutable registry digests and never builds application images on the production host. -For the reviewed conversational rectification and global birthplace schema set, -run `.github/workflows/apply-production-rectification-migrations.yml` with -`operation=check` first. If the ledger and checksums are clean, rerun the same -current-`main` revision with `operation=apply`. The workflow only accepts the -four allowlisted forward migrations, applies each migration and its ledger row -in one transaction, and refuses stale revisions or checksum drift. +The `internal` verification mode is for pre-DNS checks on the new host. The `public` mode is for the same SHA after authoritative DNS and Caddy TLS converge. Neither mode imports data, applies pending migrations, or changes DNS. -Required GitHub Actions secret: +Required Gitea Actions secret: ```text -PRODUCTION_SSH_PRIVATE_KEY = dedicated production deploy private key +PRODUCTION_SSH_PRIVATE_KEY = one-line base64 of the dedicated deploy private-key file ``` -The workflow pins the VPS Ed25519 host key and serializes deployments with the `production` concurrency group. +The independently verified host key is stored in `PRODUCTION_KNOWN_HOSTS`. See the production migration runbook for all variables, data gates, and rollback boundaries. ## Staging deployment @@ -188,7 +164,9 @@ CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat ``` -Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands. +模型供应商的 `base_url` 不再依赖域名白名单,任意公网 HTTPS origin 均可由管理员配置;部署环境不需要 `MODEL_PROVIDER_BASE_URL_ALLOWLIST`。服务端仍强制 HTTPS、禁止凭据、localhost/内网/保留地址,并在 DNS 解析、请求地址 pinning 和重定向处理上执行 SSRF 防护。 + +Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the four role-specific server-only database URLs, the exact `AUTH_USER_ORIGIN=https://staging.jyotisha.chat` and `ADMIN_USER_ORIGIN=https://admin.staging.jyotisha.chat`, the single `BETTER_AUTH_USER_SECRET`, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Both hosts run the same application and Better Auth service, but cookies remain host-only; the admin host `/` redirects to `/admin`, and unauthenticated admin requests continue to `/login` on that host. Better Auth trusts only those two origins, while unknown identity hosts fail closed. Persisted `identity.users.role=admin` is the only self-hosted backend role, while `viewer` and ordinary users are denied. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production uses the same architecture only after the reviewed migration and cutover. See `docs/operations/self-hosted-identity.md` for identity validation. 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. @@ -202,8 +180,6 @@ After source sync and before `up`, the workflow validates `.env.staging` mode/se 6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually with the same full SHA. Migration success does not dispatch deployment. 7. After migration succeeds, the operator must manually start `Deploy staging` from `main` with that same exact SHA, then confirm `https://staging.jyotisha.chat/api/health` reports it and private API health. -After the exact-SHA deployment and migrations are verified, use the manual `Configure Staging Rectification Rollout` workflow to change new-case creation. Supply the SHA currently reported by `/api/health`; choose `public` to open all staging accounts, `smoke_only` with canonical test-account UUIDs for a canary, or `paused` to close creation. The workflow updates only the four `RECTIFICATION_V3_*` rollout variables under the shared host lock, recreates `web` and `rectification-v4-worker` with the already deployed image, and rolls back the env file if health does not match the requested audience. Do not edit or print `.env.staging` through CI logs. - 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: @@ -333,23 +309,7 @@ This disposable staging procedure does not authorize a production migration, pro ## Manual deployment fallback -If GitHub Actions is unavailable, deploy the tracked tree without copying local secrets: - -```bash -cd /Users/jesse/Downloads/Copse/astrology/yinduzhanxing -git status --short --branch -rsync -az --delete \ - --exclude='.git/' \ - --exclude='.env.production' \ - --exclude='frontend/node_modules/' \ - --exclude='frontend/.next/' \ - -e 'ssh -p 22000' \ - ./ root@103.117.123.53:/opt/jyotisha-app/ -ssh -p 22000 root@103.117.123.53 \ - 'cd /opt/jyotisha-app && docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build --remove-orphans' -``` - -The excluded `.env.production` remains only on the VPS. +There is no unreviewed rsync/build fallback for the new production. If Gitea Actions or the immutable artifact is unavailable, stop the release and restore the control plane; do not substitute a mutable image tag or copy a local working tree to production. ## Verification @@ -361,139 +321,43 @@ curl -fsS -o /dev/null -w '%{http_code}\n' https://jyotisha.chat/api/account The second command should return `401` while logged out. Verify the private Python API from inside the web container: ```bash -ssh -p 22000 root@103.117.123.53 \ - 'cd /opt/jyotisha-app && docker compose --env-file .env.production -f deploy/docker-compose.server.yml exec -T web node -e "fetch(\"http://api:5200/api/health\").then(async r=>{console.log(r.status); console.log(await r.text())})"' +ssh -p deploy@118.194.235.34 \ + 'cd /opt/jyotisha-production && docker compose -p jyotisha-production --env-file .env.production -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml -f deploy/docker-compose.production.yml exec -T web node -e "fetch(\"http://api:5200/api/health\").then(async r=>{console.log(r.status); console.log(await r.text())})"' ``` -Expected: HTTP `200`, `"status": "ok"`, and `"swisseph_available": true`. Public access to `103.117.123.53:5200` must fail. +Expected: HTTP `200`, `"status": "ok"`, and `"swisseph_available": true`. Public access to `118.194.235.34:5200` and `:5432` must fail. The deployment workflow also executes `SELECT 1` through all four runtime database roles. -Before deploying application code that depends on any new Supabase migration (columns, tables, grants, policies, or RPCs), run `cd frontend && npx supabase db push --linked`; the GitHub deployment workflow does not apply database migrations. Multi-model chat specifically requires `20260717010000_chat_session_model.sql` before the new web image is deployed. Then manually verify: OTP login, onboarding/profile persistence, per-session `model_id` persistence, code redemption, admin code generation, authenticated `/api/models` returns only sanitized public metadata, invalid model IDs are rejected before charging, each configured model can answer, the 2.5-second free undo window, streaming response, one-credit charge, refund before the first output chunk, and charged stop with partial output preserved after streaming starts. +Do not treat a green app deployment as proof of database migration or data reconciliation. The production deployment refuses pending target migrations but does not apply them. Follow the schema-first ETL and verification gates in the production migration runbook. Supabase remains the read-only rollback source until the retention and reconciliation window closes. -For the July 2026 new-user profile save fix, either run the manual GitHub Action -`Apply Supabase profile migrations` after adding `SUPABASE_DB_URL` or `DATABASE_URL` -to `/opt/jyotisha-app/.env.production`, or execute these five SQL migrations in -the Supabase SQL Editor with a project member account: +## Agentic birth-time rectification -- `20260718010000_recover_missing_profile_rows.sql` -- `20260718020000_profiles_service_role_upsert_grants.sql` -- `20260718050000_profiles_service_role_upsert_grants.sql` -- `20260718070000_profiles_service_role_upsert_id.sql` -- `20260718080000_profiles_service_role_account_upsert_selects.sql` +The maintained web flow uses `POST /api/rectification/agent`. It has no standalone +rectification worker and no V3/V4 rollout selector. Apply all pending forward +migrations before deploying the matching web image; never delete or reverse the +historical migrations or their billing, receipt, and audit rows. -Do not treat a green app deployment as proof this database step ran. If the SQL -Editor shows `You do not have access to this project`, use the correct Supabase -organization account or invite the current GitHub user to project -`vtvnfqmonbfuxmqkqdlc` before retrying. - -## Conversational birth-time rectification v3 rollout - -`conversational-evidence-v3` is an account-level workflow. A web-image rollout -does not prove its database contract is present. Apply migrations before the -web image, in this order: - -1. `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql` -2. `20260720010000_conversational_rectification_schema.sql` -3. `20260720020000_conversational_rectification_billing.sql` -4. `20260720030000_conversational_rectification_transitions.sql` -5. `20260720040000_rectification_question_handoff.sql` -6. `20260721010000_conversational_legacy_import_projection.sql` - -Run `cd frontend && npx supabase db push --linked` with the authorized project -account. Verify the linked migration ledger contains all six versions. Do not -print the database URL or any service-role credential. Then set -`RECTIFICATION_V3_MIGRATIONS_READY=true`, keep -`RECTIFICATION_V3_CREATE_ENABLED=true`, set -`RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS` to exactly one canonical UUID for -the synthetic account, leave `RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` empty, and -deploy the tested Git revision. Never print, log, copy into a ticket, or return -that UUID from health or telemetry. Creation is available only for the -allowlisted smoke account; ordinary authenticated users can still resume and -finish existing cases but cannot start a paid or legacy-imported case. - -Before the smoke, fetch `https://jyotisha.chat/api/health` and verify the full -deployment SHA, healthy dependencies, enabled creation, ready migrations, -`creationAudience: smoke_only`, `syntheticSmoke: pending`, and -`readyForNewCases: false`. A missing, abbreviated, malformed, or -previous-revision smoke SHA must remain pending. If the create flag, migration -flag, deployment SHA, or strict UUID allowlist is invalid, creation audience -must be `paused`, including for the smoke account. - -After the smoke sequence below passes, use the guarded rollout workflow to set -`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` to the exact deployed 40-character -lowercase Git SHA, remove `RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, enable -`RECTIFICATION_AGENT_V5_ENABLED=true`, disable shadow mode, set the canary to -100 percent, and restart both the web and rectification worker containers. The -workflow writes these selectors together so public Case creation cannot silently -fall back to the fixed `v4_legacy` projector. Then fetch health again and verify -all of the following against the revision that passed validation: - -- `deployment.gitCommit` exactly equals the tested 40-character Git SHA; -- `rollout.conversationalRectificationV3.protocol` is - `conversational-evidence-v3`; -- `newCaseCreation` and `migrations` are `enabled` and `ready`; -- `creationAudience` is `public`; -- `syntheticSmoke` is `matched`; -- `readyForNewCases` is `true`; -- ordinary health checks remain healthy. The health response must never contain - environment values or credentials. - -Using an authorized synthetic account with no real birth data, run this smoke -sequence. A plain HTTP `200` is not substitute evidence: - -1. Finish onboarding without rectification. Verify an unverified reported time - offers current-chat consent or `先校正再询问`. -2. Save a synthetic ordinary question and start v3. Verify one fixed fee and a - rich first turn containing the candidate boundary, stable/sensitive layers, - domain rationale, and a dated historical-event request. -3. Answer with one explicit event, choose `都不符合`, submit one ambiguous - event, then a clear event. Verify the ambiguous/future facts do not score. -4. Pause, reload, and resume from a second authenticated browser session. - Verify no second rectification charge. -5. Reach a stable candidate range and verify the prior active time remains in - force. Confirm that no exact minute can be accepted and that rectification - does not write `profiles.active_birth_time`. -6. Explicitly continue the saved ordinary question. Verify one normal - consultation reservation. Delete its chat and verify the account case still - resumes/loads. -7. For an unfinished legacy case, verify exactly one - `migration_waived` import, unchanged history, and no broad-year questionnaire. -8. Inject one transient 502. Verify byte-identical retry and stable Chinese - fallback, never raw browser English. - -Record only protocol, phase, action kind, result category, latency bucket, -billing state, error category, and deployment SHA. Narrative, event text, birth -data, email, user/user-case identifiers, tokens, and model prompts are forbidden -from telemetry. - -### Rollback - -Rollback is forward-compatible and non-destructive. First set -`RECTIFICATION_V3_CREATE_ENABLED=false`, clear -`RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA` and -`RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS`, and redeploy a revision that can still -read/resume v3. Health must report `newCaseCreation: paused`. This stops only -new v3 starts: keep reads, resume, answer, pause, confirmation, and saved-question -handoff available for existing cases. Never reverse or delete the v3 migrations, -rows, turns, evidence, receipts, or legacy import links. Never point an imported -case back to mutable legacy history. A revision in progress keeps the account's -prior active time until its exact atomic confirmation succeeds. If no compatible -reader is available, leave the current image serving existing cases and disable -only creation; do not deploy an older schema consumer. +After an exact-SHA deployment, verify `/api/health`, then use a synthetic account +to confirm the browser calls `/api/rectification/agent`. Confirm retired unfinished +cases do not block the Agentic +entry, charging remains idempotent, and a previously accepted or confirmed +profile time is not changed without an explicit current acceptance. Do not record +birth data, narrative, account identifiers, tokens, or model prompts in smoke +evidence. ## Common operations ```bash # Restart without rebuilding -docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d +docker compose -p jyotisha-production --env-file .env.production \ + -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml \ + -f deploy/docker-compose.production.yml up -d -# Rebuild only the web container -docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build web caddy - -# Rebuild only the Python API -docker compose --env-file .env.production -f deploy/docker-compose.server.yml up -d --build api +# Pull/deploy application images only through the exact-SHA Gitea workflow. # Follow logs -docker compose --env-file .env.production -f deploy/docker-compose.server.yml logs -f --tail=100 api web caddy +docker compose -p jyotisha-production --env-file .env.production \ + -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml \ + -f deploy/docker-compose.production.yml logs -f --tail=100 postgres api web caddy ``` ## Optional Railway deployment diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh deleted file mode 100755 index ce8fe30e..00000000 --- a/deploy/configure-staging-rectification-rollout.sh +++ /dev/null @@ -1,191 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail -set +x - -required=(DEPLOY_PATH EXPECTED_DEPLOY_SHA ROLLOUT_AUDIENCE STAGING_URL) -for key in "${required[@]}"; do - if [ -z "${!key:-}" ]; then - echo "required staging rollout input is missing: $key" >&2 - exit 1 - fi -done - -sha_pattern='^[0-9a-f]{40}$' -uuid_pattern='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' -[[ "$EXPECTED_DEPLOY_SHA" =~ $sha_pattern ]] || { - echo "invalid expected deployment SHA" >&2 - exit 1 -} -case "$ROLLOUT_AUDIENCE" in - paused|smoke_only|public) ;; - *) echo "invalid rollout audience" >&2; exit 1 ;; -esac - -smoke_user_ids="${SYNTHETIC_SMOKE_USER_IDS:-}" -if [ "$ROLLOUT_AUDIENCE" = "smoke_only" ]; then - [ -n "$smoke_user_ids" ] || { - echo "smoke_only requires at least one synthetic user UUID" >&2 - exit 1 - } - IFS=',' read -ra smoke_users <<<"$smoke_user_ids" - for user_id in "${smoke_users[@]}"; do - [[ "$user_id" =~ $uuid_pattern ]] || { - echo "invalid synthetic smoke user UUID" >&2 - exit 1 - } - done -else - [ -z "$smoke_user_ids" ] || { - echo "synthetic smoke users are only valid for smoke_only" >&2 - exit 1 - } -fi - -state_directory="$DEPLOY_PATH/.state" -env_file="$DEPLOY_PATH/.env.staging" -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 -} - -compose_files=( - -f deploy/docker-compose.server.yml - -f deploy/docker-compose.postgres.yml - -f deploy/docker-compose.staging.yml -) - -[ -f "$env_file" ] && [ ! -L "$env_file" ] || { - echo "staging environment file is missing or unsafe" >&2 - exit 1 -} -EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || stat -f '%u' "$DEPLOY_PATH")" -EXPECTED_STAGING_ENV_OWNER_GID="$(stat -c '%g' "$DEPLOY_PATH" 2>/dev/null || stat -f '%g' "$DEPLOY_PATH")" -[[ "$EXPECTED_STAGING_ENV_OWNER_UID" =~ ^[0-9]+$ && "$EXPECTED_STAGING_ENV_OWNER_GID" =~ ^[0-9]+$ ]] || { - echo "staging deployment owner is invalid" >&2 - exit 1 -} -export EXPECTED_STAGING_ENV_OWNER_UID -bash "$DEPLOY_PATH/deploy/validate-staging-env.sh" "$env_file" -current_sha="$(<"$state_directory/deployed-revision")" -[ "$current_sha" = "$EXPECTED_DEPLOY_SHA" ] || { - echo "deployed staging revision does not match the approved rollout SHA" >&2 - exit 1 -} - -case "$ROLLOUT_AUDIENCE" in - public) - creation_enabled=true - smoke_sha="$EXPECTED_DEPLOY_SHA" - smoke_user_ids="" - ;; - smoke_only) - creation_enabled=true - smoke_sha="" - ;; - paused) - creation_enabled=false - smoke_sha="" - smoke_user_ids="" - ;; -esac - -backup="$(mktemp "$state_directory/rectification-rollout-backup.XXXXXX")" -temporary="$(mktemp "$DEPLOY_PATH/.env.staging.rollout.XXXXXX")" -declare -a compose=() -cleanup() { rm -f -- "$backup" "$temporary"; } -rollback() { - local status=$? - cp -p -- "$backup" "$env_file" - if [ "${#compose[@]}" -gt 0 ]; then - "${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker >/dev/null 2>&1 || true - fi - exit "$status" -} -trap cleanup EXIT -cp -p -- "$env_file" "$backup" - -awk \ - -v create="$creation_enabled" \ - -v migrations="true" \ - -v smoke_sha="$smoke_sha" \ - -v smoke_users="$smoke_user_ids" \ - -v agent_enabled="$creation_enabled" ' -BEGIN { - values["RECTIFICATION_V3_CREATE_ENABLED"] = create - values["RECTIFICATION_V3_MIGRATIONS_READY"] = migrations - values["RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA"] = smoke_sha - values["RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS"] = smoke_users - values["RECTIFICATION_AGENT_V5_ENABLED"] = agent_enabled - values["RECTIFICATION_AGENT_V5_SHADOW"] = "false" - values["RECTIFICATION_AGENT_V5_CANARY_PERCENT"] = "100" -} -{ - split($0, parts, "=") - key = parts[1] - if (key in values) { - if (!(key in written)) print key "=" values[key] - written[key] = 1 - next - } - print -} -END { - for (key in values) if (!(key in written)) print key "=" values[key] -} -' "$env_file" >"$temporary" -chown "$EXPECTED_STAGING_ENV_OWNER_UID:$EXPECTED_STAGING_ENV_OWNER_GID" "$temporary" -chmod 600 "$temporary" - -cd "$DEPLOY_PATH" -bash deploy/validate-staging-env.sh "$temporary" staging.jyotisha.chat deploy/Caddyfile.staging -mv -f -- "$temporary" "$env_file" -trap rollback ERR - -web_container="$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1)" -[ -n "$web_container" ] || { - echo "staging web container is missing" >&2 - false -} -export WEB_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$web_container")" -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="$EXPECTED_DEPLOY_SHA" -compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_files[@]}") - -"${compose[@]}" config --quiet -"${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker - -for service in web rectification-v4-worker; do - container="$(docker ps -q --filter 'label=com.docker.compose.project=jyotisha-staging' --filter "label=com.docker.compose.service=$service" | head -n 1)" - [ -n "$container" ] || { - echo "staging $service container is missing after rollout" >&2 - false - } - runtime_env="$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$container")" - grep -Fxq "RECTIFICATION_AGENT_V5_ENABLED=$creation_enabled" <<<"$runtime_env" - grep -Fxq "RECTIFICATION_AGENT_V5_SHADOW=false" <<<"$runtime_env" - grep -Fxq "RECTIFICATION_AGENT_V5_CANARY_PERCENT=100" <<<"$runtime_env" -done - -health="" -for _ in $(seq 1 30); do - health="$(curl --fail --silent --show-error "$STAGING_URL/api/health" 2>/dev/null || true)" - expected_ready=false - [ "$ROLLOUT_AUDIENCE" = public ] && expected_ready=true - if grep -Fq "\"gitCommit\":\"$EXPECTED_DEPLOY_SHA\"" <<<"$health" && - grep -Fq "\"creationAudience\":\"$ROLLOUT_AUDIENCE\"" <<<"$health" && - grep -Fq "\"readyForNewCases\":$expected_ready" <<<"$health"; then - trap - ERR - printf 'rectification rollout audience=%s deployed_sha=%s ready_for_new_cases=%s\n' \ - "$ROLLOUT_AUDIENCE" "$EXPECTED_DEPLOY_SHA" "$([ "$ROLLOUT_AUDIENCE" = public ] && echo true || echo false)" - exit 0 - fi - sleep 2 -done - -echo "staging rollout health verification failed" >&2 -false diff --git a/deploy/docker-compose.production.yml b/deploy/docker-compose.production.yml new file mode 100644 index 00000000..aab684bb --- /dev/null +++ b/deploy/docker-compose.production.yml @@ -0,0 +1,8 @@ +services: + web: + networks: + - default + - app + +networks: + app: diff --git a/deploy/docker-compose.staging.yml b/deploy/docker-compose.staging.yml index 7619dcd2..aab684bb 100644 --- a/deploy/docker-compose.staging.yml +++ b/deploy/docker-compose.staging.yml @@ -4,24 +4,5 @@ services: - default - app - rectification-v4-worker: - image: ${WEB_IMAGE:-jyotisha-web:local} - restart: unless-stopped - env_file: - - ${APP_ENV_FILE:-../.env.staging} - environment: - GITHUB_SHA: ${GITHUB_SHA} - JYOTISH_API_BASE: http://api:5200 - working_dir: /app/frontend - command: ["npm", "run", "worker:rectification-v4"] - depends_on: - api: - condition: service_healthy - postgres: - condition: service_healthy - networks: - - default - - app - networks: app: diff --git a/deploy/postgres/001-bootstrap-roles.sh b/deploy/postgres/001-bootstrap-roles.sh index e55cb784..e4e82344 100755 --- a/deploy/postgres/001-bootstrap-roles.sh +++ b/deploy/postgres/001-bootstrap-roles.sh @@ -81,6 +81,8 @@ SELECT format( SELECT 1 FROM pg_roles WHERE rolname = 'backup_reader' ) \gexec +GRANT schema_owner TO migration_runner; + SELECT format( 'GRANT CONNECT, CREATE ON DATABASE %I TO schema_owner', :'database_name' diff --git a/deploy/prepare-staging-model-provider-env.sh b/deploy/prepare-staging-model-provider-env.sh new file mode 100755 index 00000000..a377811b --- /dev/null +++ b/deploy/prepare-staging-model-provider-env.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +ENV_FILE="${1:-.env.staging}" +if [ ! -f "$ENV_FILE" ] || [ -L "$ENV_FILE" ]; then + echo "staging environment file is missing or unsafe: $ENV_FILE" >&2 + exit 1 +fi + +legacy_pattern='^(OPENAI_API_KEY|ANTHROPIC_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY|LLM_MODELS_JSON|LLM_BASE_URL|LLM_MODEL|LLM_DEFAULT_MODEL_ID|LLM_PROVIDER_ID|MASTRA_MODEL|MODEL_PROVIDER_[A-Z0-9_]+_API_KEY)=' +key_count="$(grep -Ec '^MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=' "$ENV_FILE" || true)" +if [ "$key_count" -gt 1 ]; then + echo "duplicate staging model provider encryption key" >&2 + exit 1 +fi + +if mode="$(stat -c '%a' "$ENV_FILE" 2>/dev/null)"; then + owner="$(stat -c '%u' "$ENV_FILE")" + group="$(stat -c '%g' "$ENV_FILE")" +else + mode="$(stat -f '%Lp' "$ENV_FILE")" + owner="$(stat -f '%u' "$ENV_FILE")" + group="$(stat -f '%g' "$ENV_FILE")" +fi +temporary="$(mktemp "${ENV_FILE}.tmp.XXXXXX")" +trap 'rm -f -- "$temporary"' EXIT + +grep -Ev "$legacy_pattern" "$ENV_FILE" > "$temporary" || true +if [ "$key_count" -eq 0 ]; then + key="$(openssl rand -base64 32 | tr -d '\n')" + [[ "$key" =~ ^[A-Za-z0-9+/]{43}=$ ]] || exit 1 + printf 'MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY=%s\n' "$key" >> "$temporary" +fi + +chmod "$mode" "$temporary" +chown "$owner:$group" "$temporary" +mv -f -- "$temporary" "$ENV_FILE" +trap - EXIT diff --git a/deploy/railway-web.Dockerfile b/deploy/railway-web.Dockerfile index cf44ea5b..a068889d 100644 --- a/deploy/railway-web.Dockerfile +++ b/deploy/railway-web.Dockerfile @@ -1,4 +1,4 @@ -FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-alpine +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-alpine AS build WORKDIR /app/frontend COPY frontend/package.json frontend/package-lock.json ./ @@ -21,7 +21,21 @@ COPY references /app/references COPY scripts /app/scripts COPY skills /app/skills -RUN npm run build && npm prune --omit=dev +RUN npm run build -ENV NODE_ENV=production -CMD ["sh", "-c", "exec npm start -- --hostname 0.0.0.0 --port \"${PORT:-3000}\""] +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-alpine + +WORKDIR /app/frontend +COPY --from=build /app/frontend/.next/standalone /app +COPY --from=build /app/frontend/.next/static /app/frontend/.next/static +COPY --from=build /app/frontend/public /app/frontend/public +COPY --from=build /app/frontend/db /app/frontend/db +COPY --from=build /app/frontend/supabase/migrations /app/frontend/supabase/migrations + +ARG NEXT_PUBLIC_SUPABASE_URL +ARG NEXT_PUBLIC_SUPABASE_ANON_KEY +ENV NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL} \ + NEXT_PUBLIC_SUPABASE_ANON_KEY=${NEXT_PUBLIC_SUPABASE_ANON_KEY} +ENV NODE_ENV=production \ + HOSTNAME=0.0.0.0 +CMD ["node", "server.js"] diff --git a/deploy/reset-staging-account.sh b/deploy/reset-staging-account.sh index 844a4050..79296889 100755 --- a/deploy/reset-staging-account.sh +++ b/deploy/reset-staging-account.sh @@ -105,12 +105,7 @@ select jsonb_build_object( 'adminAuditLogs', snapshot.admin_audit_logs, 'chatSessions', (select count(*) from public.chat_sessions value where value.user_id = snapshot.id), 'chartProfiles', (select count(*) from public.chart_profiles value where value.user_id = snapshot.id), - 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = snapshot.id), - 'legacyRectificationCases', (select count(*) from public.birth_time_rectification_cases value where value.user_id = snapshot.id), - 'v5RectificationCases', (select count(*) from public.birth_time_rectification_v4_cases value where value.user_id = snapshot.id), - 'v5AgentRuns', (select count(*) from public.birth_time_rectification_agent_runs value where value.user_id = snapshot.id), - 'v5Diagnostics', (select count(*) from public.birth_time_rectification_diagnostics value where value.user_id = snapshot.id), - 'v5Jobs', (select count(*) from public.birth_time_rectification_v4_jobs value where value.user_id = snapshot.id) + 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = snapshot.id) ) from reset_snapshot snapshot; @@ -151,8 +146,6 @@ where profile.id = snapshot.id; delete from public.chat_sessions value using reset_snapshot snapshot where value.user_id = snapshot.id; delete from public.chart_profiles value using reset_snapshot snapshot where value.user_id = snapshot.id; delete from public.synastry_reports value using reset_snapshot snapshot where value.user_id = snapshot.id; -delete from public.birth_time_rectification_v4_cases value using reset_snapshot snapshot where value.user_id = snapshot.id; -delete from public.birth_time_rectification_cases value using reset_snapshot snapshot where value.user_id = snapshot.id; do $$ begin @@ -182,14 +175,6 @@ begin if exists (select 1 from public.chat_sessions value join reset_snapshot snapshot on value.user_id = snapshot.id) or exists (select 1 from public.chart_profiles value join reset_snapshot snapshot on value.user_id = snapshot.id) or exists (select 1 from public.synastry_reports value join reset_snapshot snapshot on value.user_id = snapshot.id) - or exists (select 1 from public.birth_time_rectification_cases value join reset_snapshot snapshot on value.user_id = snapshot.id) - or exists (select 1 from public.birth_time_rectification_v4_cases value join reset_snapshot snapshot on value.user_id = snapshot.id) - or exists (select 1 from public.birth_time_rectification_v4_jobs value join reset_snapshot snapshot on value.user_id = snapshot.id) - or exists (select 1 from public.birth_time_rectification_agent_runs value join reset_snapshot snapshot on value.user_id = snapshot.id) - or exists (select 1 from public.birth_time_rectification_diagnostics value join reset_snapshot snapshot on value.user_id = snapshot.id) - or exists (select 1 from public.birth_time_rectification_candidate_feature_snapshots value join reset_snapshot snapshot on value.user_id = snapshot.id) - or exists (select 1 from public.birth_time_rectification_public_messages value join reset_snapshot snapshot on value.user_id = snapshot.id) - or exists (select 1 from public.birth_time_rectification_pending_evidence value join reset_snapshot snapshot on value.user_id = snapshot.id) or exists ( select 1 from public.profiles profile join reset_snapshot snapshot on profile.id = snapshot.id where profile.name is not null or profile.birth_date is not null or profile.birth_time is not null @@ -246,14 +231,6 @@ begin if exists (select 1 from public.chat_sessions value where value.user_id = target_id) or exists (select 1 from public.chart_profiles value where value.user_id = target_id) or exists (select 1 from public.synastry_reports value where value.user_id = target_id) - or exists (select 1 from public.birth_time_rectification_cases value where value.user_id = target_id) - or exists (select 1 from public.birth_time_rectification_v4_cases value where value.user_id = target_id) - or exists (select 1 from public.birth_time_rectification_v4_jobs value where value.user_id = target_id) - or exists (select 1 from public.birth_time_rectification_agent_runs value where value.user_id = target_id) - or exists (select 1 from public.birth_time_rectification_diagnostics value where value.user_id = target_id) - or exists (select 1 from public.birth_time_rectification_candidate_feature_snapshots value where value.user_id = target_id) - or exists (select 1 from public.birth_time_rectification_public_messages value where value.user_id = target_id) - or exists (select 1 from public.birth_time_rectification_pending_evidence value where value.user_id = target_id) or exists (select 1 from postflight_target where profile_not_reset) then raise exception 'postflight_reset_state_not_empty'; end if; @@ -269,14 +246,6 @@ select jsonb_build_object( 'chatSessions', (select count(*) from public.chat_sessions value where value.user_id = (select id from postflight_target)), 'chartProfiles', (select count(*) from public.chart_profiles value where value.user_id = (select id from postflight_target)), 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = (select id from postflight_target)), - 'legacyRectificationCases', (select count(*) from public.birth_time_rectification_cases value where value.user_id = (select id from postflight_target)), - 'v5RectificationCases', (select count(*) from public.birth_time_rectification_v4_cases value where value.user_id = (select id from postflight_target)), - 'v5Jobs', (select count(*) from public.birth_time_rectification_v4_jobs value where value.user_id = (select id from postflight_target)), - 'v5AgentRuns', (select count(*) from public.birth_time_rectification_agent_runs value where value.user_id = (select id from postflight_target)), - 'v5Diagnostics', (select count(*) from public.birth_time_rectification_diagnostics value where value.user_id = (select id from postflight_target)), - 'v5FeatureSnapshots', (select count(*) from public.birth_time_rectification_candidate_feature_snapshots value where value.user_id = (select id from postflight_target)), - 'v5PublicMessages', (select count(*) from public.birth_time_rectification_public_messages value where value.user_id = (select id from postflight_target)), - 'v5PendingEvidence', (select count(*) from public.birth_time_rectification_pending_evidence value where value.user_id = (select id from postflight_target)), 'identityAccounts', (select count(*) from identity.accounts value where value.user_id = (select id from postflight_target)), 'identitySessions', (select count(*) from identity.sessions value where value.user_id = (select id from postflight_target)), 'creditTransactions', (select count(*) from public.credit_transactions value where value.user_id = (select id from postflight_target)), diff --git a/deploy/run-production-deploy.sh b/deploy/run-production-deploy.sh new file mode 100755 index 00000000..610e4444 --- /dev/null +++ b/deploy/run-production-deploy.sh @@ -0,0 +1,337 @@ +#!/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 PRODUCTION_URL + PRODUCTION_ADMIN_URL VERIFICATION_MODE +) +case "${DOCKER_BIN:-docker}" in + docker) docker_command=(docker) ;; + "sudo -n docker") docker_command=(sudo -n docker --config "$DOCKER_CONFIG") ;; + *) echo "unsafe production Docker command" >&2; exit 1 ;; +esac +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required production deployment input is missing: $key" >&2 + exit 1 + fi +done + +sha_pattern='^[0-9a-f]{40}$' +digest_pattern='^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[1-9][0-9]{0,4})?(/[a-z0-9]+([._-][a-z0-9]+)*)+@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 production image identity" >&2 + exit 1 +fi +api_repository="${API_IMAGE%@sha256:*}" +web_repository="${WEB_IMAGE%@sha256:*}" +if [ "$ALLOW_ROLLBACK" != "true" ] && [ "$ALLOW_ROLLBACK" != "false" ]; then + echo "invalid rollback authorization" >&2 + exit 1 +fi +if [ "$VERIFICATION_MODE" != "internal" ] && [ "$VERIFICATION_MODE" != "public" ]; then + echo "invalid production verification mode" >&2 + exit 1 +fi +case "$INCOMING_PATH" in + /tmp/jyotisha-production.*) ;; + *) echo "unsafe incoming production 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 production 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_command[@]}" ps -aq \ + --filter 'label=com.docker.compose.project=jyotisha-production' \ + --filter 'label=com.docker.compose.service=web' | head -n 1)" + if [ -n "$existing_web" ]; then + discovered_sha="$("${docker_command[@]}" 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 production revision state" >&2 + exit 1 +fi +if [ "$current_sha" != "$EXPECTED_PREVIOUS_SHA" ]; then + echo "production 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 production revision was not verified" >&2 + exit 1 +fi + +container_id() { + "${docker_command[@]}" ps -aq \ + --filter 'label=com.docker.compose.project=jyotisha-production' \ + --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_command[@]}" inspect --format '{{.Image}}' "$id")" + "${docker_command[@]}" 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 "$api_repository")" +previous_web_image="$(repo_digest_for_container web "$web_repository")" +previous_api_id="" +previous_web_id="" +if [ -n "$(container_id api)" ]; then + previous_api_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$(container_id api)")" +fi +if [ -n "$(container_id web)" ]; then + previous_web_id="$("${docker_command[@]}" 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-production-tree.sh" \ + "$INCOMING_PATH" "$DEPLOY_PATH" + +cd "$DEPLOY_PATH" +EXPECTED_PRODUCTION_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || stat -f '%u' "$DEPLOY_PATH")" +[[ "$EXPECTED_PRODUCTION_ENV_OWNER_UID" =~ ^[0-9]+$ ]] || { + echo "production deployment owner is invalid" >&2 + exit 1 +} +export EXPECTED_PRODUCTION_ENV_OWNER_UID +bash deploy/validate-production-env.sh \ + .env.production +bash deploy/validate-production-database-env.sh .env.production.database + +compose=( + "${docker_command[@]}" compose -p jyotisha-production --env-file .env.production + -f deploy/docker-compose.server.yml -f deploy/docker-compose.postgres.yml + -f deploy/docker-compose.production.yml +) +export APP_ENV_FILE='../.env.production' +export DATABASE_ENV_FILE='../.env.production.database' +export CADDYFILE_PATH='./Caddyfile.production.selfhosted' +export SITE_ADDRESS='https://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 Production Database for $DEPLOY_SHA" >&2 + exit 3 +fi +if [ "$check_status" -ne 0 ]; then + echo "production 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 "production verification failed; restoring prior application images" >&2 + rollback_services=(api web) + if [ "$VERIFICATION_MODE" = "public" ]; then rollback_services+=(caddy); fi + API_IMAGE="$previous_api_target" WEB_IMAGE="$previous_web_target" \ + GITHUB_SHA="$current_sha" \ + "${compose[@]}" up -d --no-build --remove-orphans \ + "${rollback_services[@]}" || true + fi + exit "$status" +} +trap rollback ERR + +switched=true +if [ "$VERIFICATION_MODE" = "public" ]; then + "${compose[@]}" up -d --no-build --remove-orphans + "${compose[@]}" up -d --no-build --force-recreate --no-deps caddy +else + # Before DNS cutover, do not trigger public certificate issuance for domains + # that still resolve to the old production host. + "${compose[@]}" up -d --no-build api web +fi + +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_command[@]}" image inspect --format '{{.Id}}' "$expected_ref")" + running_id="$("${docker_command[@]}" inspect --format '{{.Image}}' "$id")" + [ "$running_id" = "$expected_id" ] + repo_digests="$("${docker_command[@]}" 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 PRODUCTION_URL="$PRODUCTION_URL" \ + -e PRODUCTION_ADMIN_URL="$PRODUCTION_ADMIN_URL" \ + -e VERIFICATION_MODE="$VERIFICATION_MODE" \ + web node --input-type=module <<'NODE' +import http from "node:http"; +import { Pool } from "pg"; + +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +for (const [role, key] of [ + ["identity", "IDENTITY_DATABASE_URL"], + ["app", "APP_DATABASE_URL"], + ["service", "SERVICE_DATABASE_URL"], + ["admin", "ADMIN_DATABASE_URL"], +]) { + const connectionString = process.env[key]; + if (!connectionString) { + console.error(`database readiness missing for ${role}`); + process.exit(1); + } + const pool = new Pool({ connectionString, max: 1, connectionTimeoutMillis: 5_000 }); + try { + const result = await pool.query("select 1 as ready"); + if (result.rows[0]?.ready !== 1) throw new Error("unexpected readiness result"); + } catch (error) { + console.error(`database readiness failed for ${role}`, error instanceof Error ? error.name : "query_error"); + process.exit(1); + } finally { + await pool.end(); + } +} + +const internal = process.env.VERIFICATION_MODE === "internal"; +const expectedUserAdminStatus = internal ? 403 : 404; +const request = (origin, path, options = {}) => { + if (!internal) return fetch(`${origin}${path}`, options); + return new Promise((resolve, reject) => { + const request = http.request({ + host: "127.0.0.1", + port: 3000, + path, + method: options.method ?? "GET", + headers: { ...options.headers, Host: new URL(origin).host }, + }, (response) => { + const chunks = []; + response.on("data", (chunk) => chunks.push(chunk)); + response.on("end", () => { + const status = response.statusCode ?? 0; + resolve({ + status, + ok: status >= 200 && status < 300, + headers: { get: (name) => response.headers[name.toLowerCase()] ?? null }, + json: async () => JSON.parse(Buffer.concat(chunks).toString("utf8")), + }); + }); + }); + request.on("error", reject); + request.end(); + }); +}; +let observed = {}; +for (let attempt = 1; attempt <= 12; attempt += 1) { + try { + const login = await request(process.env.PRODUCTION_URL, "/login"); + const userAdminPage = await request(process.env.PRODUCTION_URL, "/admin", { redirect: "manual" }); + const userAdminApi = await request(process.env.PRODUCTION_URL, "/api/admin/session"); + const adminPage = await request(process.env.PRODUCTION_ADMIN_URL, "/admin", { redirect: "manual" }); + const adminApi = await request(process.env.PRODUCTION_ADMIN_URL, "/api/admin/session"); + const account = await request(process.env.PRODUCTION_URL, "/api/account"); + const publicHealth = await request(process.env.PRODUCTION_URL, "/api/health"); + const publicBody = await publicHealth.json(); + const privateHealth = await fetch("http://api:5200/api/health"); + const privateBody = await privateHealth.json(); + observed = { + attempt, + login: login.status, + userAdminPage: userAdminPage.status, + userAdminApi: userAdminApi.status, + adminPage: adminPage.status, + adminLocation: adminPage.headers.get("location"), + adminApi: adminApi.status, + account: account.status, + publicHealth: publicHealth.status, + publicSha: publicBody.deployment?.gitCommit ?? "missing", + privateHealth: privateHealth.status, + privateStatus: privateBody.status ?? "missing", + swissephAvailable: privateBody.swisseph_available === true, + verificationMode: process.env.VERIFICATION_MODE, + }; + if ( + login.ok + && userAdminPage.status === expectedUserAdminStatus + && userAdminApi.status === expectedUserAdminStatus + && adminPage.status === 307 + && adminPage.headers.get("location") === "/login" + && adminApi.status === 401 + && account.status === 401 + && publicHealth.ok + && publicBody.deployment?.gitCommit === process.env.EXPECTED_SHA + && privateHealth.ok + && privateBody.status === "ok" + && privateBody.swisseph_available === true + ) { + process.exit(0); + } + } catch (error) { + observed = { + attempt, + error: error instanceof Error ? error.name : "verification_error", + }; + } + if (attempt < 12) await delay(5_000); +} +console.error("production verification predicates did not converge", JSON.stringify(observed)); +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-production-migration.sh b/deploy/run-production-migration.sh new file mode 100755 index 00000000..6d1a66e1 --- /dev/null +++ b/deploy/run-production-migration.sh @@ -0,0 +1,200 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=( + INCOMING_PATH DEPLOY_PATH WEB_IMAGE DEPLOY_SHA EXPECTED_PREVIOUS_SHA + RECOVERY_REFERENCE RECOVERY_CREATED_AT RESTORE_VERIFIED DOCKER_CONFIG +) +case "${DOCKER_BIN:-docker}" in + docker) docker_command=(docker) ;; + "sudo -n docker") docker_command=(sudo -n docker --config "$DOCKER_CONFIG") ;; + *) echo "unsafe production Docker command" >&2; exit 1 ;; +esac +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required production migration input is missing: $key" >&2 + exit 1 + fi +done + +[[ "$DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "unsafe production migration revision" >&2 + exit 1 +} +image_pattern='^crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com/copse/jyotisha@sha256:[0-9a-f]{64}$' +[[ "$WEB_IMAGE" =~ $image_pattern ]] || { + echo "unsafe production migration image" >&2 + exit 1 +} +case "$INCOMING_PATH" in + /tmp/jyotisha-production-migration.*) ;; + *) echo "unsafe incoming production migration path" >&2; exit 1 ;; +esac +[ "$DEPLOY_PATH" = "/opt/jyotisha-production" ] || { + echo "unsafe production deployment path" >&2 + exit 1 +} +[ "$DOCKER_CONFIG" = "$INCOMING_PATH/.docker" ] || { + echo "unsafe production Docker configuration path" >&2 + exit 1 +} +[[ "$RECOVERY_REFERENCE" =~ ^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,199}$ ]] || { + echo "unsafe production recovery reference" >&2 + exit 1 +} +[[ "$RECOVERY_CREATED_AT" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$ ]] || { + echo "unsafe production recovery creation time" >&2 + exit 1 +} +[ "$RESTORE_VERIFIED" = "true" ] || { + echo "production recovery point must have restore_verified=true" >&2 + exit 1 +} +recovery_created_epoch="$(date -u -d "$RECOVERY_CREATED_AT" +%s 2>/dev/null)" || { + echo "invalid production recovery creation time" >&2 + exit 1 +} +recovery_now_epoch="$(date -u +%s)" +recovery_age_seconds=$((recovery_now_epoch - recovery_created_epoch)) +(( recovery_age_seconds >= 0 && recovery_age_seconds <= 24 * 60 * 60 )) || { + echo "production recovery point must be no more than 24 hours old and not in the future" >&2 + exit 1 +} +echo "Recovery attested: reference=$RECOVERY_REFERENCE created_at=$RECOVERY_CREATED_AT restore_verified=true" +echo "WARNING: migration files run sequentially and are not atomic as a whole; recovery may be required after a partial migration." >&2 + +state_directory="$DEPLOY_PATH/.state" +install -d -m 700 "$state_directory" +exec 9>"$state_directory/mutation.lock" +flock -n 9 || { + echo "another production 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_command[@]}" ps -aq \ + --filter 'label=com.docker.compose.project=jyotisha-production' \ + --filter 'label=com.docker.compose.service=web' | head -n 1)" + if [ -n "$existing_web" ]; then + discovered_sha="$("${docker_command[@]}" 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 production revision state" >&2 + exit 1 +fi +[ "$current_sha" = "$EXPECTED_PREVIOUS_SHA" ] || { + echo "production 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 production revision was not verified" >&2 + exit 1 + } + +bash "$INCOMING_PATH/deploy/sync-production-tree.sh" \ + "$INCOMING_PATH" "$DEPLOY_PATH" + +cd "$DEPLOY_PATH" +EXPECTED_PRODUCTION_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || stat -f '%u' "$DEPLOY_PATH")" +[[ "$EXPECTED_PRODUCTION_ENV_OWNER_UID" =~ ^[0-9]+$ ]] || { + echo "production deployment owner is invalid" >&2 + exit 1 +} +export EXPECTED_PRODUCTION_ENV_OWNER_UID +bash deploy/validate-production-env.sh .env.production +bash deploy/validate-production-database-env.sh .env.production.database + +export DATABASE_ENV_FILE='../.env.production.database' +compose=("${docker_command[@]}" compose -p jyotisha-production -f deploy/docker-compose.postgres.yml) +"${docker_command[@]}" pull "$WEB_IMAGE" +"${compose[@]}" config --quiet +"${compose[@]}" up -d --no-build --pull never --wait postgres + +membership="$("${compose[@]}" exec -T postgres psql -v ON_ERROR_STOP=1 -U postgres -d jyotisha -Atc \ + "select pg_has_role('migration_runner', 'schema_owner', 'member')")" +[ "$membership" = "t" ] || { + echo "migration_runner must be allowed to SET ROLE schema_owner before production migration" >&2 + exit 1 +} + +environment_value() { + local key="$1" + local value + value="$(sed -n -E "s/^[[:space:]]*(export[[:space:]]+)?${key}[[:space:]]*=[[:space:]]*(.*)$/\\2/p" .env.production.database)" + case "$value" in + \"*\") value="${value:1:${#value}-2}" ;; + \'*\') value="${value:1:${#value}-2}" ;; + esac + printf '%s' "$value" +} + +percent_encode() { + local value="$1" + local encoded="" + local character hex index + LC_ALL=C + for ((index = 0; index < ${#value}; index += 1)); do + character="${value:index:1}" + case "$character" in + [a-zA-Z0-9.~_-]) encoded+="$character" ;; + *) + printf -v hex '%%%02X' "'$character" + encoded+="$hex" + ;; + esac + done + printf '%s' "$encoded" +} + +migration_runner_password="$(environment_value MIGRATION_RUNNER_PASSWORD)" +[ -n "$migration_runner_password" ] || { + echo "production migration runner password is missing" >&2 + exit 1 +} +encoded_migration_runner_password="$(percent_encode "$migration_runner_password")" +unset migration_runner_password +migration_runner_database_url="postgresql://migration_runner:${encoded_migration_runner_password}@postgres:5432/jyotisha?options=-c%20role%3Dschema_owner" +unset encoded_migration_runner_password + +migration_environment="$(mktemp "$state_directory/production-migration-env.XXXXXXXXXX")" +cleanup_migration_environment() { + rm -f -- "$migration_environment" +} +trap cleanup_migration_environment EXIT +chmod 600 "$migration_environment" +printf 'SCHEMA_DATABASE_URL=%s\n' "$migration_runner_database_url" >"$migration_environment" +unset migration_runner_database_url + +set +e +DATABASE_ENV_FILE="$migration_environment" \ + "${compose[@]}" --profile migration-check run --rm migration-checker +precheck_status=$? +set -e +if [ "$precheck_status" -ne 0 ] && [ "$precheck_status" -ne 3 ]; then + echo "production migration precheck failed safely" >&2 + exit "$precheck_status" +fi + +DATABASE_ENV_FILE="$migration_environment" \ + "${compose[@]}" --profile migration run --rm migrator + +set +e +DATABASE_ENV_FILE="$migration_environment" \ + "${compose[@]}" --profile migration-check run --rm migration-checker +postcheck_status=$? +set -e +if [ "$postcheck_status" -ne 0 ]; then + echo "production migration postcheck did not converge" >&2 + exit "$postcheck_status" +fi + +echo "production schema migration verified for $DEPLOY_SHA" diff --git a/deploy/run-staging-deploy.sh b/deploy/run-staging-deploy.sh index 4326cf20..4fd18af4 100755 --- a/deploy/run-staging-deploy.sh +++ b/deploy/run-staging-deploy.sh @@ -126,6 +126,7 @@ EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || sta exit 1 } export EXPECTED_STAGING_ENV_OWNER_UID +bash deploy/prepare-staging-model-provider-env.sh .env.staging bash deploy/validate-staging-env.sh \ .env.staging staging.jyotisha.chat deploy/Caddyfile.staging bash deploy/validate-staging-database-env.sh .env.staging.database @@ -169,7 +170,7 @@ rollback() { API_IMAGE="$previous_api_target" WEB_IMAGE="$previous_web_target" \ GITHUB_SHA="$current_sha" \ "${compose[@]}" up -d --no-build --remove-orphans \ - api web rectification-v4-worker caddy || true + api web caddy || true fi exit "$status" } @@ -193,7 +194,6 @@ verify_container_image() { } verify_container_image api "$API_IMAGE" verify_container_image web "$WEB_IMAGE" -verify_container_image rectification-v4-worker "$WEB_IMAGE" "${compose[@]}" exec -T \ -e EXPECTED_SHA="$DEPLOY_SHA" -e STAGING_URL="$STAGING_URL" \ diff --git a/deploy/run-staging-migration.sh b/deploy/run-staging-migration.sh index 60dda620..743e30a8 100755 --- a/deploy/run-staging-migration.sh +++ b/deploy/run-staging-migration.sh @@ -78,6 +78,7 @@ EXPECTED_STAGING_ENV_OWNER_UID="$(stat -c '%u' "$DEPLOY_PATH" 2>/dev/null || sta exit 1 } export EXPECTED_STAGING_ENV_OWNER_UID +bash deploy/prepare-staging-model-provider-env.sh .env.staging bash deploy/validate-staging-env.sh \ .env.staging staging.jyotisha.chat deploy/Caddyfile.staging bash deploy/validate-staging-database-env.sh .env.staging.database diff --git a/deploy/sync-production-tree.sh b/deploy/sync-production-tree.sh new file mode 100755 index 00000000..debd6ebf --- /dev/null +++ b/deploy/sync-production-tree.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [ "$#" -ne 2 ] || [ ! -d "$1" ] || [ ! -d "$2" ]; then + echo "usage: sync-production-tree.sh SOURCE_DIRECTORY DESTINATION_DIRECTORY" >&2 + exit 1 +fi + +destination_deploy="$2/deploy" +if [ -d "$destination_deploy" ]; then + docker run --rm --pull never --network none --read-only --user 0:0 \ + --cap-drop ALL --cap-add CHOWN --security-opt no-new-privileges \ + -v "$destination_deploy:/destination" postgres:17-alpine \ + chown -R "$(id -u):$(id -g)" /destination + chmod -R u+rwX "$destination_deploy" +fi + +rsync -az --delete --no-owner --no-group \ + --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-production-database-env.sh b/deploy/validate-production-database-env.sh new file mode 100755 index 00000000..fa2002d1 --- /dev/null +++ b/deploy/validate-production-database-env.sh @@ -0,0 +1,123 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +ENV_FILE="${1:-.env.production.database}" + +if [ ! -e "$ENV_FILE" ]; then + echo "production database environment file is missing" >&2 + exit 1 +fi + +if [ -L "$ENV_FILE" ]; then + echo "production database environment file must not be a symlink" >&2 + exit 1 +fi + +if [ ! -f "$ENV_FILE" ]; then + echo "production 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 "production 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 +EXPECTED_OWNER_UID="${EXPECTED_PRODUCTION_ENV_OWNER_UID:-$(id -u)}" +if [[ ! "$EXPECTED_OWNER_UID" =~ ^[0-9]+$ ]] || [ "$OWNER" != "$EXPECTED_OWNER_UID" ]; then + echo "production database environment file has an invalid owner" >&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 production 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 + SERVICE_RUNTIME_PASSWORD ADMIN_RUNTIME_PASSWORD MIGRATION_RUNNER_PASSWORD + BACKUP_READER_PASSWORD + PRODUCTION_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 production database selector: POSTGRES_DB" >&2 + exit 1 +fi + +if [ "$(environment_value POSTGRES_USER)" != "postgres" ]; then + echo "invalid production 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 production database selector: SCHEMA_DATABASE_URL" >&2 + exit 1 +fi + +echo "production database environment validated" diff --git a/deploy/validate-production-env.sh b/deploy/validate-production-env.sh new file mode 100755 index 00000000..dd6d928b --- /dev/null +++ b/deploy/validate-production-env.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE="${1:-.env.production}" + +if [ ! -f "$ENV_FILE" ]; then + echo "production environment file is missing: $ENV_FILE" >&2 + exit 1 +fi + +if [ -L "$ENV_FILE" ]; then + echo "production environment file must not be a symlink" >&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 "production 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 +EXPECTED_OWNER_UID="${EXPECTED_PRODUCTION_ENV_OWNER_UID:-$(id -u)}" +if [[ ! "$EXPECTED_OWNER_UID" =~ ^[0-9]+$ ]] || [ "$OWNER" != "$EXPECTED_OWNER_UID" ]; then + echo "production environment file has an invalid owner" >&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 production selector: $key" >&2 + exit 1 + fi +} + +require_selector APP_ENV_FILE ../.env.production +require_selector CADDYFILE_PATH ./Caddyfile.production.selfhosted +require_selector SITE_ADDRESS https://jyotisha.chat +require_selector AUTH_PROVIDER self-hosted +require_selector SELF_HOSTED_IDENTITY_ENABLED true +require_selector AUTH_USER_ORIGIN https://jyotisha.chat +require_selector ADMIN_USER_ORIGIN https://admin.jyotisha.chat + +require_literal() { + local key="$1" + local minimum_length="$2" + local count value + count="$(grep -Ec "^${key}=" "$ENV_FILE" || true)" + if [ "$count" -ne 1 ]; then + echo "invalid production identity setting: $key" >&2 + exit 1 + fi + value="$(grep -E "^${key}=" "$ENV_FILE")" + value="${value#*=}" + if [ "${#value}" -lt "$minimum_length" ] || + [[ "$value" == *'$'* || "$value" == *'"'* || "$value" == *"'"* ]]; then + echo "invalid production identity setting: $key" >&2 + exit 1 + fi + LITERAL_VALUE="$value" +} + +require_literal IDENTITY_DATABASE_URL 50 +identity_database_url="$LITERAL_VALUE" +if ! [[ "$identity_database_url" =~ ^postgresql://identity_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid production identity setting: IDENTITY_DATABASE_URL" >&2 + exit 1 +fi + +require_literal APP_DATABASE_URL 45 +app_database_url="$LITERAL_VALUE" +if ! [[ "$app_database_url" =~ ^postgresql://app_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid production database setting: APP_DATABASE_URL" >&2 + exit 1 +fi + +require_literal SERVICE_DATABASE_URL 49 +service_database_url="$LITERAL_VALUE" +if ! [[ "$service_database_url" =~ ^postgresql://service_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid production database setting: SERVICE_DATABASE_URL" >&2 + exit 1 +fi + +require_literal ADMIN_DATABASE_URL 45 +admin_database_url="$LITERAL_VALUE" +if ! [[ "$admin_database_url" =~ ^postgresql://admin_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid production database setting: ADMIN_DATABASE_URL" >&2 + exit 1 +fi + +require_literal BETTER_AUTH_USER_SECRET 32 +require_literal RESEND_API_KEY 10 +require_literal RESEND_FROM_EMAIL 5 +if [[ "$LITERAL_VALUE" != *@* ]]; then + echo "invalid production identity setting: RESEND_FROM_EMAIL" >&2 + exit 1 +fi +require_literal ADMIN_EMAILS 3 +if [[ "$LITERAL_VALUE" != *@* ]]; then + echo "invalid production identity setting: ADMIN_EMAILS" >&2 + exit 1 +fi +require_literal EPAY_CONFIG_ENCRYPTION_KEY 44 +if [ "${#LITERAL_VALUE}" -ne 44 ] || + [[ ! "$LITERAL_VALUE" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then + echo "invalid production identity setting: EPAY_CONFIG_ENCRYPTION_KEY" >&2 + exit 1 +fi +require_literal MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY 44 +if [ "${#LITERAL_VALUE}" -ne 44 ] || + [[ ! "$LITERAL_VALUE" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then + echo "invalid production model provider setting: MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY" >&2 + exit 1 +fi +legacy_model_setting_pattern='^(OPENAI_API_KEY|ANTHROPIC_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY|LLM_MODELS_JSON|LLM_BASE_URL|LLM_MODEL|LLM_DEFAULT_MODEL_ID|LLM_PROVIDER_ID|MASTRA_MODEL|MODEL_PROVIDER_[A-Z0-9_]+_API_KEY)=' +if grep -Eq "$legacy_model_setting_pattern" "$ENV_FILE"; then + echo "legacy model environment settings are forbidden" >&2 + exit 1 +fi + +require_selector EPAY_CHAT_ENABLED false +require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32 + +personal_report_enabled_count="$(grep -Ec '^PERSONAL_REPORT_ENABLED=' "$ENV_FILE" || true)" +personal_report_enabled="$(grep -E '^PERSONAL_REPORT_ENABLED=' "$ENV_FILE" || true)" +personal_report_enabled="${personal_report_enabled#*=}" +if [ "$personal_report_enabled_count" -ne 1 ] || + [[ "$personal_report_enabled" != "true" && "$personal_report_enabled" != "false" ]]; then + echo "invalid production personal report setting: PERSONAL_REPORT_ENABLED" >&2 + exit 1 +fi + +personal_report_daily_limit_count="$(grep -Ec '^PERSONAL_REPORT_DAILY_LIMIT=' "$ENV_FILE" || true)" +personal_report_daily_limit="$(grep -E '^PERSONAL_REPORT_DAILY_LIMIT=' "$ENV_FILE" || true)" +personal_report_daily_limit="${personal_report_daily_limit#*=}" +if [ "$personal_report_daily_limit_count" -ne 1 ] || + [[ ! "$personal_report_daily_limit" =~ ^[1-9][0-9]*$ ]]; then + echo "invalid production personal report setting: PERSONAL_REPORT_DAILY_LIMIT" >&2 + exit 1 +fi + +echo "production environment selectors: valid" diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index b0e96aaf..ddd735ee 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -122,6 +122,18 @@ if [ "${#LITERAL_VALUE}" -ne 44 ] || echo "invalid staging identity setting: EPAY_CONFIG_ENCRYPTION_KEY" >&2 exit 1 fi +require_literal MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY 44 +if [ "${#LITERAL_VALUE}" -ne 44 ] || + [[ ! "$LITERAL_VALUE" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then + echo "invalid staging model provider setting: MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY" >&2 + exit 1 +fi +legacy_model_setting_pattern='^(OPENAI_API_KEY|ANTHROPIC_API_KEY|DEEPSEEK_API_KEY|LLM_API_KEY|LLM_MODELS_JSON|LLM_BASE_URL|LLM_MODEL|LLM_DEFAULT_MODEL_ID|LLM_PROVIDER_ID|MASTRA_MODEL|MODEL_PROVIDER_[A-Z0-9_]+_API_KEY)=' +if grep -Eq "$legacy_model_setting_pattern" "$ENV_FILE"; then + echo "legacy model environment settings are forbidden" >&2 + exit 1 +fi + require_selector EPAY_CHAT_ENABLED false require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ad7f0d2c..d695cf9f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -2385,21 +2385,21 @@ - 复发自:无 - 修复版本:`6c1dcbe857006ec6ae7463b57b2b7d5947da4851` -## BUG-139 | 系统 Python 启动预检时重复使用不兼容解释器 +## BUG-139 | staging 后台缺失初始 Owner 且拒绝重定向与 Caddy 形成无限循环 -- 状态:resolved +- 状态:resolved(local candidate,pending review/deployment) - 首次发现:2026-08-07 - 最近更新:2026-08-07 -- 影响面:`scripts/pre_work_check.py`、开工预检、碎片扫描与聚焦治理测试 -- 用户现象:每次执行文档规定的 `python3 scripts/pre_work_check.py ...`,碎片扫描都会因 Python 3.9 不支持项目使用的 PEP 604 类型注解而退出,聚焦测试同时报 `/usr/bin/python3: No module named pytest`。 -- 触发条件:macOS 的裸 `python3` 指向 `/usr/bin/python3` 3.9,而仓库 `.venv` 已安装 Python 3.11 与 pytest。 -- 根因:预检脚本把启动自身的 `sys.executable` 固定为全部子命令的解释器,没有验证项目声明的 Python >=3.11,也没有优先使用仓库虚拟环境;`ERR-078` 原防线只要求操作者手动改用 `.venv`,因此文档中的标准命令仍会稳定复发。 -- 修复:预检入口按确定顺序探测项目 `.venv`、当前解释器和可用的 Python 3.11+;只有同时满足 Python >=3.11 与 pytest 可用才执行全部 Python 子检查。报告显式记录选择结果和各候选探测信息;找不到兼容环境时失败闭合并给出安装指引。新增严格的 `JYOTISH_PRE_WORK_PYTHON` 覆盖入口,配置错误时不静默退回其他解释器。 -- 验证:`tests/test_pre_work_check.py` 覆盖虚拟环境优先、系统 3.9 跳过、严格 override 和 runtime failure 状态;使用裸 `/usr/bin/python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 完整通过,报告选择仓库 `.venv/bin/python` 3.11 且 focused governance tests 通过。 -- 防复发:预检不得再直接用未经探测的 `sys.executable` 启动项目脚本或 pytest;标准文档命令必须纳入真实系统 Python 启动回归,且 JSON 报告必须保留 `python_runtime_ok` 和实际解释器信息。 -- 相关记录:ERR-011、ERR-014、ERR-078 -- 复发自:无 -- 修复版本:待提交 +- 影响面:`admin.staging.jyotisha.chat` 后台入口、self-hosted admin RBAC 初始 Owner 恢复;production 未受影响。 +- 用户现象:用户完成后台域名登录后访问 `/`,浏览器报 `ERR_TOO_MANY_REDIRECTS`;未认证公开链仍正常表现为 `/` 308 到 `/admin`、再 307 到 `/login`、最终 200。 +- 触发条件:已认证 self-hosted 用户通过身份 session,但 `admin_permission_keys` 没有返回 `admin.access`,后台 gate 产生 403;历史同域 fallback 将所有非 401 授权错误重定向到 `/`,而独立后台 Caddy 又将 `/` 永久重定向到 `/admin`。 +- 根因:第一层是双 host 发布后仍保留 BUG-123 的同域 `403/503 -> /` 行为,与 BUG-138 的后台根路径 `308 -> /admin` 组合成确定性循环。第二层是 `20260806010000_admin_rbac.sql` 的一次性 bootstrap 只捕获迁移执行当时已经是 identity admin 的用户;staging 脱敏聚合显示 `identity_admins=1`、`auth_users=1`、`bootstrap_eligible_admins=1`,但 `active_admin_users=0`、`owner_assignments=0`、`identity_admins_missing_rbac=1`,因此当前唯一 active identity admin 没有 RBAC Owner,真实授权结果为 403,而不是 cookie/host 隔离或数据库不可用。 +- 修复:后台 layout 对 401 仍转 `/login`,403 使用 Next.js forbidden interrupt 返回明确 403 页面;503 以 307 转到 admin layout 外的独立 `/admin-unavailable` route,由该 route 最终返回 503 和 `cache-control: no-store`。后台根 route 对 403/503 直接返回对应状态和 `no-store` 文本响应,所有拒绝路径都不再导向 `/`。授权边界现将读取 self-hosted 配置、Host 判定、Better Auth/session、identity DB 与 RBAC DB 查询置于同一异常边界:既有 `AdminAuthorizationError` 原样保留,identity 401/403 继续映射为脱敏 401/403,Host 不匹配仍为 403,其余未知基础设施异常统一转换为 `后台服务暂时不可用` 503,API `adminErrorResponse` 保留该最终状态。Owner 恢复 migration 保留在 `frontend/db/migrations`,但在任何表查询前用 `to_regclass`/`to_regprocedure` 检查 identity/auth 表、RBAC 表与关键函数;identity-only `MIGRATIONS_DIRECTORY=db/migrations` 缺少 RBAC 时安全 no-op 并正常记账,full staging 合并两目录后按文件名排序,在 `20260806010000_admin_rbac.sql` 之后执行恢复,而 Supabase-only/production migration 集合不包含该文件,不污染 production Supabase ledger。恢复语义仍为:已有 active Owner no-op;真正空库 no-op;只有恰好一个当前可登录(未封禁,或封禁截止时间已过)、已同步 `auth.users`、持久 identity role 包含 `admin`,且 `admin_users` 不存在或尚未 revoked 的候选才恢复 Owner。历史 revoked admin 明确排除,`admin_users` 冲突使用 `do nothing`,不得清除 `revoked_at/revoked_by`;零个或多个候选均以约束错误 fail closed。运行时授权继续只依赖数据库 RBAC,不读取 `ADMIN_EMAILS`,也不批量授权所有 identity admin。 +- 验证:重定向合同覆盖 `401 -> /login`、403 forbidden、嵌套页面 503 只转 `/admin-unavailable` 以及 Caddy `/ -> /admin` 不成环;直接执行独立 route handler 验证最终响应为 503、`no-store`、无 `Location`。可执行授权单元测试覆盖配置 reader、Better Auth/identity reader 与 RBAC query 未知故障均脱敏为 503,既有授权异常与 identity 401/403 不变,并直接验证 `adminErrorResponse` 最终返回 503。真实 PostgreSQL fixture 验证 db-only identity migration 在 RBAC 缺失时安全 no-op、full 两目录流程执行恢复,以及空库 no-op、已过期封禁和带空格角色的单一同步候选可恢复、未同步或仍封禁账号不可恢复、已有 Owner 时第二个 identity admin 不获授权、revoked 历史管理员不会复活且撤销字段保持不变、两个候选和零候选均 fail closed。聚焦与完整测试、lint、TypeScript、构建结果见本次候选提交验证记录。 +- 防复发:独立后台 host 的拒绝路径不得使用相对 `/` 作为逃生路由;401、403、503 必须分别保留认证、授权和服务故障语义,layout 不能把 503 吞成 500,授权依赖的未知配置/provider/数据库错误也不得泄露或退化为 500。跨 identity 与 RBAC 的恢复 migration 必须留在 DB migration ledger,并以显式 schema/function 前置检查兼容 identity-only no-op;不能放入 production 使用的 Supabase-only ledger。一次性 RBAC bootstrap 后新增的初始管理员必须通过受约束向前 migration 或显式角色管理进入权限图;历史撤销是安全边界,禁止自动清除,也禁止用邮箱 allowlist 或“所有 identity admin”兜底。 +- 相关记录:BUG-123、BUG-134、BUG-138 +- 复发自:BUG-123 +- 修复版本:本次 staging admin redirect/RBAC recovery 候选提交 ## BUG-140 | 首页入口卡片点击后持续显示灰色交互态 @@ -2416,3 +2416,283 @@ - 相关记录:无 - 复发自:无 - 修复版本:待提交 + +## BUG-141 | self-hosted PostgreSQL DATE 行导致 consultation 503 + +- 状态:resolved(local candidate) +- 首次发现:2026-08-07 +- 影响面:self-hosted staging 的 profile/consultation 读取;production 未受影响。 +- 根因:本地 PostgreSQL adapter 将 `DATE` 查询结果保留为 JavaScript `Date`,下游业务合同要求无时区的 `YYYY-MM-DD`。 +- 修复:按列类型将查询结果中的 `DATE` 归一化为本地日历字符串,timestamp 仍保持 `Date`;同步删除 legacy rectification 后的 account stale test。 +- 验证:focused 回归 37/37、完整前端 1017/1017、local quick quality gate 291 passed/1 skipped;TypeScript、lint(0 error)、production build 与 `git diff --check` 通过。 +- 修复版本:本次 staging-only 集成候选 + +## BUG-142 | Gitea staging publish job Run 1540 post-job timeout + +- 状态:resolved(local candidate) +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:Gitea `Staging Backend Quality Gate` publish job;validate、exact-SHA 与安全校验未修改,production 未涉及。 +- 用户现象:Run 1540 在发布步骤完成后进入 post-job timeout,质量门禁未能正常收口。 +- 触发条件:publish job 的 45 分钟 job timeout 不足以覆盖发布后的收尾阶段。 +- 根因:publish job 与 validate job 共用 45 分钟上限,未为发布收尾保留独立余量。 +- 修复:仅将 `.gitea/workflows/backend-quality-gate.yml` 的 publish timeout 从 45 分钟调整为 60 分钟;validate 仍为 45 分钟,安全检查与 exact-SHA 行为保持不变。 +- 验证:新增合同断言区分 validate=45 与 publish=60;聚焦 staging workflow test、YAML 解析和 `git diff --check` 通过;未 push、deploy 或触碰 production。 +- 防复发:合同测试必须同时锁定 validate 与 publish 的 job timeout,避免发布收尾预算被误改。 +- 相关记录:BUG-136 +- 复发自:无 +- 修复版本:本次提交(staging-only) + +## BUG-143 | 新增 membership 页面未同步能力审计精确路由集合 + +- 状态:resolved(local candidate,远端 gate 待 follow-up 更新) +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:`tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources`、Gitea staging quality gate;会员页实现本身未由本记录改动。 +- 用户现象:staging gate run `1549` 中 290 passed、1 skipped、1 failed;能力审计已扫描到新 `membership` 页面(位于 `login` 之后、`reports/[reportId]` 之前的排序位置),但测试仍精确断言旧集合,publish 被跳过,自动 deploy 未发生。 +- 触发条件:新增 `frontend/src/app/membership/page.tsx` 后运行 capability audit 精确集合回归。 +- 根因:这是 BUG-126(reports 页面)、BUG-132(后台 14 页面)同类精确路由集合防复发模式第三次复发。旧防线失效的直接原因是会员页本地交付矩阵只运行了 frontend tests(`membership-page/sidebar/starter/epay` 等静态合同),没有把 Python 侧 `test_capability_audit_scans_registry_and_local_sources` 纳入同变更验证,因此真实 App Router 路由集合与测试期望再次分叉;该测试由 staging quality gate 动态扫描 `frontend/src/app` 才能捕获。 +- 修复:在预期排序位置 `login` 之后、`reports/[reportId]` 之前加入 `membership`,保留精确完整集合,不放宽为子集/包含断言。 +- 验证:本地目标节点 `/Users/jesse/Documents/Jyotisha/.venv/bin/python -m pytest tests/test_api_server_security.py::test_capability_audit_scans_registry_and_local_sources -q` 通过(1 passed);`git diff --check` 通过;远端 staging gate 需在 follow-up 提交/推送后更新确认,本记录不提前声称远端收口。 +- 防复发:新增或删除任何 `frontend/src/app/**/page.tsx` 页面时,必须在本变更中同步运行 `test_capability_audit_scans_registry_and_local_sources`(或完整 `test_api_server_security.py` 聚焦切片),并在本地验证通过后才进入推送门禁;前端本地矩阵不能替代该 Python 能力审计节点。 +- 相关记录:BUG-126、BUG-132 +- 复发自:BUG-126(模式:新增页面未同步能力审计精确路由集合) +- 修复版本:待 follow-up commit / gate + +## BUG-144 | db/migrations 副本依赖业务 schema 导致 identity-only fixture 迁移失败 + +- 状态:resolved(local candidate,远端 gate 待 follow-up 更新) +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:`frontend/db/migrations/20260807020000_redeem_security.sql`(已删除)、`frontend/tests/redeem-orders-contract.test.ts`、`frontend/tests/database-redeem-security.test.ts`;staging quality gate run `1552` frontend 1054 passed / 2 failed,Python quick 291 passed / 1 skipped,publish / deploy 均未发生。 +- 用户现象:gate run `1552` 数据库真实失败,publish 被跳过,自动 deploy 未发生。 +- 触发条件:新增依赖业务 schema(`public.redemption_codes`、`public.credit_transactions`)的 redemption 安全迁移时,同时在 `frontend/db/migrations` 与 `frontend/supabase/migrations` 各放一份;`database-local-business` 聚合迁移(两目录全量)通过,而只应用 `frontend/db/migrations` 的 identity-only fixture 中业务 schema 尚不存在。 +- 根因:`database-self-hosted-identity` 与 `identity-auth-integration` 只应用 `frontend/db/migrations`(identity foundation),新的 db 副本假设 `public.redemption_codes` 已由 supabase compatibility 迁移创建;identity-only 序列因此 `migration failed`,与 BUG-127 同类(新增业务表必须精确选择迁移位置/全迁移),但不是 BUG-127 或 BUG-143 的复发,属本变更独立根因。 +- 修复:删除 `frontend/db/migrations/20260807020000_redeem_security.sql`,仅保留 `frontend/supabase/migrations/20260807030000_redeem_security.sql`;contract 测试只审该单一 migration,删除 byte-identical 双副本断言,并新增硬防线:断言 `frontend/db/migrations/20260807020000_redeem_security.sql` 不存在(`existsSync === false`);DB security 测试 aggregate runner 只期望 `applied 20260807030000_redeem_security.sql`,不再期待 070200,并断言 stdout 不含 `20260807020000_redeem_security`;SQL 内容未作任何变更。 +- 验证:本机无 Docker,无法执行 identity-only 与 aggregate DB 实测;运行 `npx tsx --test tests/redeem-orders-contract.test.ts`(7 passed,原 6 项 + 新增 existsSync 防复发守卫 1 项)、`tsc --noEmit`(clean)与 `git diff --check` 通过;目标 identity DB 实测(`database-self-hosted-identity`、`identity-auth-integration`)与 aggregate DB 测试(`database-local-business`、`database-redeem-security`)留待远端 gate 确认,本记录不提前声称远端收口。 +- 防复发:`frontend/db/migrations` 只放 identity foundation 独立可执行迁移;依赖业务 schema(`public.redemption_codes`、`payment_orders` 等)的迁移只进 `frontend/supabase/migrations`;任何新增/删除迁移必须在本变更中同时跑 identity-only 两测试(`database-self-hosted-identity`、`identity-auth-integration`)与 aggregate DB 测试(`database-local-business` 等)。 +- 相关记录:BUG-127、BUG-143 +- 复发自:无(独立根因,非 BUG-127 / BUG-143 复发) +- 修复版本:待 follow-up commit / gate + +## BUG-145 | staging Web health 被空模型目录错误阻断 + +- 状态:resolved(local candidate,远端 gate 待 follow-up 更新) +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:`frontend/src/app/api/health/route.ts` 的 Web 容器 healthcheck;数据库尚未发布默认模型时 staging Web 被错误判定为 unhealthy,真实基础设施故障的 503 语义不变。 +- 用户现象:staging 数据库没有 published default model 时,model catalog 只有 `default_model_unavailable` 或 `database_model_catalog_empty`,但 `/api/health` 返回 HTTP 503,导致 Web bootstrap/Compose healthcheck 互相等待,页面无法进入稳定 healthy 状态。 +- 根因:health route 将所有非 `ok` 聚合状态统一映射为 503;同时把可恢复的模型目录缺少默认模型状态标成 `blocked`,混淆了业务配置未就绪与模型目录数据库基础设施不可用。上一轮放宽为 `degraded -> 200` 后,又会把 Jyotish API 非 2xx 误报为 healthy。 +- 修复:模型目录缺少 published default model 时报告 `degraded`;`database_model_catalog_unavailable` 与 Jyotish API 非 2xx 保持 `blocked`。整体 health 仅在存在 `blocked` 检查时返回 503,模型目录缺省配置仍返回 200;未恢复 legacy model env vars,也未改变 consult/report/onboarding 的 fail-closed 模型解析路径。 +- 验证:`npx tsx --test tests/health-deployment.test.ts`(12 passed);`npx eslint src/app/api/health/route.ts tests/health-deployment.test.ts`(通过);`git diff --check`(通过)。 +- 防复发:health contract 必须同时覆盖“无 published default model -> `modelCatalog=degraded`、HTTP 200”、“Jyotish API 非 2xx -> `blocked`”和“任意 `blocked` -> HTTP 503”,不得让真实基础设施故障落入 `degraded`。 +- 相关记录:BUG-145 +- 复发自:无(staging bootstrap deadlock 的独立 health contract 根因) +- 修复版本:待 follow-up commit / gate + +## BUG-146 | staging 后台写请求把 Caddy 上游协议误当公开来源 + +- 状态:resolved +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:所有经 `requireAdminMutation` 的后台写接口、`admin.staging.jyotisha.chat` 模型管理写操作;普通 staging 用户域名、其他后台功能的通用 `ReasonActionModal` 与 production 未改动。 +- 用户现象:管理员在独立后台域名提交 `POST /api/admin/models` 时,浏览器 `Origin` 为 HTTPS 公开后台域名,但 Caddy 转发后的 Route Handler 请求 URL 使用上游 HTTP 协议,旧校验因此返回 403 `请求来源不可信`。 +- 触发条件:请求经 staging Caddy `reverse_proxy web:3000` 进入 Next.js,公开 origin 与上游 `request.url` 协议不同;旧 `isSameOriginAdminMutation` 只比较这两个 origin,未核对 Caddy 提供的公开 Host/Proto 头。 +- 根因:共享后台 mutation guard 把应用上游 URL 当作浏览器公开来源真值,没有结合既有 `ADMIN_USER_ORIGIN`、原始 `Host` 与 Caddy 的 `X-Forwarded-Host` / `X-Forwarded-Proto`;因此合法后台请求被拒绝,同时也不能安全地仅信任任意 forwarded host。 +- 修复:`requireAdminMutation` 统一调用可测试的共享来源策略;配置 `ADMIN_USER_ORIGIN` 时要求浏览器 Origin 精确匹配、`Host` 与规范化后的 forwarded host 一致、公开 host/proto 精确匹配后台 origin,缺失、歧义、畸形或冲突的 forwarded 值全部 fail closed;未配置后台 origin 的既有直连环境继续使用严格 same-origin fallback。复用 identity host 规范化 helper,未新增同义 env,staging Caddy 继续在用户域名对 `/admin*` 与 `/api/admin/*` 返回 404。模型管理同时删除 saveProvider/saveDraft/publish/rollback 的客户端“操作原因”字段与交互,服务端分别注入固定中文审计说明后继续传给原 DB procedure 的非空 reason 参数;通用 `ReasonActionModal` 未改动。 +- 验证:`npx tsx --test tests/admin-http-origin.test.ts tests/admin-model-management-ui-contract.test.ts tests/identity-host-routing.test.ts tests/admin-reauth.test.ts tests/health-deployment.test.ts`(34 passed);相关 ESLint、`git diff --check` 与最终差异审查见本提交验证记录。 +- 防复发:后台 mutation 来源测试必须同时覆盖合法 admin Origin + 公开 host/proto、错误 Origin、普通 staging host、Host/forwarded host 冲突、逗号多值、畸形 host、缺失 proto 与非法 `ADMIN_USER_ORIGIN`;模型管理合同必须拒绝客户端 reason,并确认四个固定审计说明仍传入现有 procedure。 +- 相关记录:BUG-134、BUG-138、BUG-139 +- 复发自:无 +- 修复版本:本地候选提交(未 push / deploy) + +## BUG-147 | staging 模型供应商保存成功但列表为空 + +- 状态:resolved(local candidate,staging migration/deploy 待验证) +- 首次发现:2026-08-08 +- 最近更新:2026-08-08 +- 现象:POST 保存成功但 GET providers 为空。 +- 触发:self-hosted `admin_runtime` 直查五张 model 表:`model_providers`、`model_configs`、`model_config_versions`、`model_publish_events`、`model_connection_test_evidence`。 +- 根因:表启用 RLS 且有 SELECT grant,但缺少 `admin_runtime` SELECT policy;`SECURITY DEFINER` 写成功、读被静默过滤。 +- 修复:`20260808020000` migration 为 `model_providers`、`model_configs`、`model_config_versions`、`model_publish_events`、`model_connection_test_evidence` 增加仅 SELECT policy。 +- 验证:focused 7/7;full frontend 1071/1071;lint 0 errors、3 warnings;build 通过;`git diff --check` 通过。 +- 防复发:模型管理数据库回归测试同时覆盖 `admin_runtime` 对五张 model 表的直接读取,迁移需保持 SELECT grant 与 SELECT policy 成对存在。 +- 相关记录:BUG-124、BUG-135、BUG-145、BUG-146 +- 复发自:无 +- 修复版本:待本次 staging SHA + +## BUG-148 | Node 22/24 all-address lookup 使模型发现 DNS pinning 失效 + +- 状态:resolved +- 首次发现:2026-08-08 +- 最近更新:2026-08-08 +- 影响面:共享 `requestAllowedModelProvider` 的固定 DNS HTTPS 请求;OpenAI-compatible 供应商模型发现无法到达已通过公网 SSRF 校验的上游。支付网关、SSRF 边界、重定向拒绝、超时与响应大小限制未放宽。 +- 用户现象:staging Web 容器内使用同一 DNS pinning/request 链路请求模型列表时失败,脱敏错误为 `ERR_INVALID_IP_ADDRESS`,已固定的地址族为 IPv4。 +- 触发条件:Node 22/24 的 `https.request` / `net.connect` 以 `lookup` 选项 `all=true` 调用自定义 DNS callback;旧实现无条件调用 `callback(null, address, family)`,而 all-address 契约要求第二参数为 `[{ address, family }]`。 +- 根因:共享 HTTPS helper 只实现了旧的单地址 lookup callback 形态,没有根据 Node 传入的 `options.all` 切换返回值;因此安全校验和 DNS 解析均成功后,Node 在建连前把字符串结果按地址数组读取并抛出 `ERR_INVALID_IP_ADDRESS`。discover route 的 provider 读取、密钥解密、`/models` URL、响应解析和错误翻译均不是本次失败根因。 +- 修复:抽出 `pinnedAddressLookup` 供共享 HTTPS 请求使用;`options.all=true` 时返回单元素已验证地址数组,其他模式继续返回 `address, family`。仍只使用已通过完整公网校验的固定地址,不重新解析、不跟随重定向,也不删除任何 SSRF/信任边界校验。 +- 验证:`npx tsx --test tests/model-provider-encrypted-credentials.test.ts` 的真实本地 TCP 回归在修复前稳定失败为 `ERR_INVALID_IP_ADDRESS`(4 passed / 1 failed),修复后对 `autoSelectFamily=true` 的 all-address 模式和 `autoSelectFamily=false` 的单地址模式均实际建连成功(5 passed / 0 failed)。 +- 防复发:DNS-pinned 请求测试必须通过 Node 网络栈实际调用自定义 lookup,并同时覆盖地址数组与单地址 callback 契约;不得用只匹配源码字符串的断言替代该回归。 +- 相关记录:BUG-146、BUG-147 +- 复发自:无 +- 修复版本:2026-08-08 staging 变更 + +## BUG-149 | staging quality gate npm ci 缺少安装级 deadline 导致 45 分钟黑洞 + +- 状态:resolved(local candidate,远端 gate/deploy 待本提交) +- 首次发现:2026-08-09 +- 最近更新:2026-08-09 +- 影响面:Gitea staging quality gate 的依赖安装阶段、`manman-linux` runner 与 Gitea/runner 控制面可用性;测试、lint、build、exact-SHA checkout/publish/deploy 合同未改变。 +- 用户现象:Run 1618(SHA `ffbe505c`)在 Python pip 完成后进入 digest-pinned Node 容器执行 `npm ci`;步骤无后续 npm 输出,直到 45 分钟 job timeout,publish/deploy skipped。 +- 触发条件:self-hosted runner 在受限 Node 容器中执行 frontend `npm ci`,但安装命令本身没有 fail-closed deadline,npm registry/fetch 也没有比 job-level 更短的诊断边界。 +- 根因:BUG-149 的宿主资源争用已由容器边界缓解,但剩余风险转移到容器内 `npm ci` 黑洞:安装命令缺少比 45 分钟 job-level timeout 更短的 hard deadline,且 npm fetch/retry 没有显式网络诊断边界;当前没有 OOM 或 PID 耗尽证据。 +- 修复:仍只将 `npm ci` 放入 digest-pinned Node 容器;保留 CPU 1.5、memory 2g、no swap、pids 256,并在容器内用 `timeout --signal=TERM --kill-after=30s 900s` 包住安装,避免外层 timeout 杀掉 docker CLI 后留下 orphan 容器。npm 增加 `--fetch-timeout=60000`、2 次重试和 1s/10s retry 上下限;超时或非零退出都输出明确诊断并 fail closed。测试、lint、build 与 exact-SHA 行为保持原样,不回退到宿主 npm。 +- 验证:本地 `node --test frontend/tests/staging-backend-workflows.test.ts` 32/32 passed;`git diff --check` 通过。远端 staging gate/deploy 待本提交后验证,本记录不提前声称远端收口。 +- 防复发:依赖安装必须维持 digest-pinned Node runtime、显式资源上限、命令级 hard timeout 与 npm 网络 timeout;合同测试需锁定只有 `npm ci` 在受限容器内执行,并持续确认测试、lint、build、exact-SHA checkout/publish/deploy 语义未漂移。 +- 相关记录:BUG-129、BUG-136、BUG-142 +- 复发自:无 +- 修复版本:待本次提交 / gate / deploy + +## BUG-150 | staging publish 的 Webpack 镜像构建耗尽共享 Gitea 资源 + +- 状态:resolved(local candidate,远端 gate/deploy 待本提交) +- 首次发现:2026-08-09 +- 最近更新:2026-08-09 +- 影响面:Gitea staging quality gate 的 publish 镜像构建、共享 Gitea/runner 可用性;production 未涉及。 +- 用户现象:Run 1634 的 validate 在约 12 分钟内成功,publish 随后在 Docker 内执行 `next build --webpack` 超过 25 分钟;期间 Gitea API 持续返回 502 和空 JSON,最终 job 中断。 +- 触发条件:`frontend/package.json` 将默认 `build` 改为 Webpack 后,Dockerfile 的 `RUN npm run build` 也继承该构建器;classic Docker builder 的慢 COPY 与资源受限 runner 进一步放大构建开销。 +- 根因:为非 push 校验引入的 Webpack 兼容参数错误地放进了全局 package script,使实际镜像发布也从已成功的 Turbopack 路径切换到高开销 Webpack;Gitea 与 runner 共享资源时因此拖垮控制面。直接重跑旧 revision 会重复相同故障。 +- 修复:恢复默认 `npm run build` 为 `next build`,让 Docker publish 继续使用 Turbopack;仅在 PR/manual 的非 push validate 分支显式追加 `-- --webpack`。staging push 继续跳过重复 production build,由 publish 镜像构建唯一验证。 +- 验证:聚焦 workflow contract 锁定默认 Turbopack、非 push Webpack 与 staging push 单次镜像构建语义;本地测试和 `git diff --check` 通过。远端 gate、publish、deploy 与 exact-SHA smoke 待本提交后验证。 +- 防复发:不得把只用于隔离 worktree/非 push 校验的构建器参数写回全局 package script;发布构建器变化必须由 workflow contract 同时覆盖 Dockerfile 与事件分支。 +- 相关记录:BUG-142、BUG-149 +- 复发自:无 +- 修复版本:待本次提交 / gate / deploy + +## BUG-151 | 手工 Release Gate 被分配到缺少 Docker Compose v2 的 runner + +- 状态:mitigated +- 首次发现:2026-08-09 +- 最近更新:2026-08-09 +- 影响面:Gitea 手工 `release-quality-gate.yml` Run `1638`;旧生产、Supabase、DNS 和新生产应用均未被切换。 +- 用户现象:候选 SHA `9235ee66f6d889e6c27e4b85411448889dd7d37d` 已通过 staging gate 并在公网 staging 运行,但手工 Release Gate 在 1113 个前端子测试中出现 17 个失败。失败均从 `docker compose --project-name` 或 `docker compose --env-file` 返回 `unknown flag` 开始。 +- 触发条件:完整 release profile 在 `xiaoxin` runner 执行 PostgreSQL/Compose 集成测试,而该 runner 只有 Docker CLI、没有可用的 Docker Compose v2 插件。 +- 根因:workflow 的工具链预检只执行 `docker version`,未验证 `docker compose`;同时 Release Gate 与已验证具备 Compose v2 的 staging/backend runner 分离,导致环境能力漂移直到完整测试阶段才暴露。 +- 修复:将手工 Release Gate 收敛到 `manman-linux`,并在依赖安装前强制 `docker compose version --short` 必须为 v2;增加 workflow 回归断言,防止重新绑定到无 Compose runner 或删除能力检查。 +- 验证:Run `1638` 的脱敏日志确认 17 个失败均由 Compose 命令不可用触发;同日 `manman-linux` 的 backend gate Run `1636` 已报告 Docker Compose `2.40.3` 并成功完成质量门;本地聚焦 workflow 测试 4/4 通过。仍需新 SHA 的手工 Release Gate 成功后再视为完整关闭。 +- 防复发:任何执行 Compose 集成测试的 runner 必须在昂贵依赖安装和测试前显式验证 Compose v2;Docker Engine 可用不能替代 Compose 能力证明。 +- 相关记录:BUG-128、BUG-129、BUG-136、ERR-095、ERR-099、ERR-103 +- 复发自:无 +- 修复版本:待新 SHA 的 Release Gate 验证 + +## BUG-152 | 订单返回套餐后再次返回会重新进入订单页 + +- 状态:resolved(local candidate) +- 首次发现:2026-08-10 +- 最近更新:2026-08-10 +- 影响面:`/membership` 与 `/membership/orders` 的浏览器返回历史;支付、订单读取、兑换与 production 未改动。 +- 用户现象:从套餐与会员页进入订单记录,点击返回套餐页后,再点击套餐页返回,会再次进入订单记录页,形成往返循环。 +- 触发条件:套餐页用普通 Link 将订单页压入历史记录,订单页返回时又用普通 Link 将套餐页再次压入历史记录。 +- 根因:订单页的“返回套餐与会员”是返回语义,却使用了默认 push 导航,历史栈变成 `套餐页 → 订单页 → 套餐页`。 +- 修复:订单页返回 Link 使用 Next.js 原生 `replace`,将当前订单页历史项替换为套餐页;套餐页再次返回时回到进入套餐前的原入口。 +- 验证:聚焦 membership 合同测试锁定返回 Link 的 `replace` 语义;前端生产构建 `next build --webpack` 通过;`git diff --check` 通过。未 push、deploy 或触碰 production。 +- 防复发:固定目标的“返回上级页”不能再次 push 当前上级页;会员页回归测试持续锁定订单页返回的 replace 语义。 +- 相关记录:无 +- 复发自:无 +- 修复版本:本地候选提交(未 push / deploy) + +## BUG-153 | 个人报告页面无法下滑且命盘与打印版式失真 + +- 状态:resolved(local candidate,待 review/deployment) +- 首次发现:2026-08-09 +- 最近更新:2026-08-09 +- 影响面:`/reports/[reportId]` 网页阅读、D1/D9/D10 命盘 SVG、浏览器打印/保存 PDF;聊天页保留既有全局滚动锁。 +- 用户现象:报告页只能通过缩小浏览器查看下方内容;打印或保存 PDF 时长文本、表格和分页缺少稳定版式;命盘内多个宫位的星体文字叠在左上宫格;当前版本还隐藏了已存在的 PDF 操作入口。 +- 触发条件:报告页运行在全局 `html, body { overflow: hidden }` 的聊天壳层中但自身没有纵向滚动边界;命盘为每个宫位绘制 occupants 时重复使用固定的局部坐标却未应用宫格偏移;打印样式继续保留屏幕表格的 `min-width`/横向滚动合同,并把可能超过一页的长摘要整体设为避免分页。 +- 根因:报告页面复用了聊天应用的根滚动策略,却没有为报告建立独立 `100dvh + overflow-y:auto` 阅读容器。`PlanetList` 的调用没有 `translate(cell.x, cell.y)`,因此十二宫共享同一绘制原点,且未对 schema 允许的密集 occupants 做行数上限和裁切。打印 CSS 只有 A4 页面与少量 `break-inside` 规则,没有清理祖先高度/overflow、表格最小宽度、长单词换行与长叙事分页。PDF 操作此前被功能隐藏提交移除,并非底层打印 helper 缺失。 +- 修复:为 ready 报告建立独立 `.personal-report-reader` 滚动容器;将报告重构为 answer-first 的研究备忘录版式(封面快照、核心判断、D1 与关键证据双栏、主题解读、证据附录),使用既有中文正文字体、暖象牙纸张色和细分隔线,不引入卡片阴影或渐变。SVG 逐宫应用 transform 与 clipPath,密集星体最多显示七项加 `+N 项`。打印时恢复祖先自动高度和可见 overflow,重置表格 `min-width`/wrapper overflow,启用 fixed layout 与强制换行,并允许长主题自然分页;A4 `@page` 只随 ready 报告 route 注入,不再污染全站打印。恢复“打印 / 保存为 PDF”操作并保留不支持打印浏览器的能力保护;附录改用原生 `details/summary`,即使客户端 hydration 延迟也可展开,打印时始终显示内容。 +- 验证:个人报告聚焦测试覆盖 answer-first 顺序、十二宫坐标/裁切/密集数据、独立滚动边界、打印表格与分页、原生附录 disclosure、PDF 操作能力保护;浏览器在 1363×936 CSS viewport 实测报告容器 `clientHeight=936`、`scrollHeight=3466`,滚动后 `scrollTop=980` 并可到达底部,附录展开后高度增至 5219;参考图与实现图完成同画布视觉比对。聚焦 ESLint、生产构建和 `git diff --check` 作为最终门禁重新执行。 +- 防复发:聊天根滚动锁与报告阅读滚动必须分层;报告回归测试不得删除 `100dvh/overflow-y:auto` 合同。任何命盘 occupants 改动必须验证至少两个不同宫位 transform、clipPath 与 12 项密集输入。打印回归必须覆盖长摘要、长主题、最小宽度表格和无滚动容器的 A4 输出;PDF 入口的隐藏/恢复必须同步更新明确的能力与 ready-state 合同。 +- 相关记录:BUG-126 +- 复发自:无 +- 修复版本:本地候选(未 push / deploy) + +## BUG-154 | 个人报告错误绑定会话且创建请求同步阻塞 + +- 状态:resolved(local candidate,待 staging gate/deployment) +- 首次发现:2026-08-09 +- 最近更新:2026-08-09 +- 影响面:个人报告入口、`/reports` 信息架构、`POST/GET /api/reports`、报告生成等待体验;报告证据合同、用户归属与每日限额不放宽。 +- 用户现象:只有进入一条已有消息的咨询 session 后才看得到“生成个人报告”,但实际报告使用的是账户出生资料而非该次对话;点击后 HTTP 请求会等待完整排盘与模型生成,用户必须停留并感知长时间阻塞,也没有集中查看历史报告的位置。 +- 触发条件:聊天页用 active session、消息数和临时 workflow receipt 控制报告 CTA;`POST /api/reports` 在插入 `generating` 记录后继续同步等待计算、模型生成、校验和 ready 写入。 +- 根因:产品入口沿用了最初的会话内 MVP,但生成主链实际构造 `entryMode=direct_chart` 并只读取认证用户的 canonical profile,会话只充当无意义的入口门槛。API 状态表已经支持 `generating/ready/failed`,却没有用响应后任务执行,也没有元数据列表端点与全局报告中心消费这些状态。 +- 修复:从聊天 header 删除 session-bound CTA,在全局侧栏加入“我的报告”并新增 `/reports` 报告中心;创建请求不再发送 `sessionId`,只携带报告身份字段。`GET /api/reports` 通过认证客户端/RLS 返回最近 20 条元数据,明确不返回正文与证据 hash。生产 POST 持久化 `generating` 后使用 Next.js 16 官方 `after()` 在响应后运行原有生成链并立即返回 202;完成/失败继续走原有 owner-scoped service 写入,意外异常也会落到稳定失败态。进程重启等中断留下的生成记录超过 15 分钟后会在下一次显式创建前安全回收为失败,避免永久占用单用户生成锁。中心与详情页每 3 秒轮询,用户可离开页面,现有 ready 报告可直接打开。 +- 验证:新增可执行回归证明 deferred 模式先返回 202 且行状态为 `generating`,执行任务后才转为 `ready`;个人报告、侧栏相关测试 185/185 通过;聚焦 ESLint、Next.js 生产构建(含 TypeScript、57 个静态页面、`/reports` 动态路由)和 `git diff --check` 通过。 +- 防复发:个人完整报告入口不得依赖 active session、消息数量或临时 workflow receipt;创建请求不得携带 chat text、出生明文或 sessionId。生产适配必须保留 response-after 调度与 202 合同,列表端点禁止返回 `report_document`、calculation/evidence hash。报告中心必须持续覆盖空、生成中、完成、失败和未登录状态。 +- 相关记录:BUG-126、BUG-153 +- 复发自:无 +- 修复版本:本地候选(待 staging push/gate) + +## BUG-155 | 后台邮箱 OTP 登录后密码状态接口误报未登录 + +- 状态:resolved(local candidate,待 staging gate/deployment) +- 首次发现:2026-08-10 +- 最近更新:2026-08-10 +- 影响面:独立后台域名的邮箱 OTP 登录、首次密码设置引导与 `GET/POST /api/account/password`;普通用户域名、未知 Host 拒绝和后台 RBAC 不放宽。 +- 用户现象:邮箱 OTP 校验成功并已创建 Better Auth session,但页面随后提示“暂时无法确认密码状态,请稍后再试”,密码状态接口返回 401“请先登录”。 +- 触发条件:浏览器在已配置的 admin origin 完成邮箱 OTP 登录后,用同一 host-only session Cookie 请求 `/api/account/password`。 +- 根因:密码状态 route 在读取 session 前把身份 surface 硬限制为 `user`;admin host 虽然是已识别身份域名且持有有效 user session,仍被提前拒绝。前端把该非 2xx 响应映射成密码状态暂不可用。 +- 修复:密码状态 route 继续要求 self-hosted identity、已识别 Host 和有效 user session,但允许 `user` 与 `admin` 两个已配置 surface;未知 Host 仍返回 401,Cookie 继续保持 host-only,不引入跨域会话共享。 +- 验证:身份集成回归新增 admin host `OTP -> session -> GET /api/account/password`,无密码账户必须返回 200 与 `hasPassword=false`;同一 Cookie 改投未知 Host 仍必须返回 401。 +- 防复发:共享 user identity session 的账户自助接口应校验“已识别身份 surface”,只有明确属于普通站的业务接口才限制 `surface=user`;任何 admin OTP 登录回归都必须继续检查登录后密码状态探测。 +- 相关记录:BUG-123、BUG-139 +- 复发自:无 +- 修复版本:本次后台 OTP 密码状态候选提交 + +## BUG-156 | self-hosted 报告创建被未实现的数据库过滤器直接打断 + +- 状态:resolved(local candidate,待 staging gate/deployment) +- 首次发现:2026-08-10 +- 最近更新:2026-08-10 +- 影响面:self-hosted staging 的 `POST /api/reports`;报告生成主链、用户归属、每日限额和 production 未放宽或改动。 +- 用户现象:合法的个人报告创建请求返回 `{"error":"报告生成暂时不可用","code":"report_generation_failed"}`。 +- 触发条件:已登录用户调用报告创建接口,route 在进入报告创建核心逻辑前先回收超过 15 分钟的 `generating` 记录。 +- 根因:回收查询使用 Supabase 风格的 `.lt("updated_at", staleBefore)`,但 self-hosted `LocalPostgresQueryBuilder` 只实现了现有的 `.lte()`;运行时在构建查询时抛出 `TypeError`,被 route 的稳定错误边界统一映射为 `report_generation_failed`。 +- 修复:复用本地 PostgreSQL 查询构建器已有的 `.lte()`,把边界定义为“更新时间小于或等于 staleBefore”;不新增过滤器 API。 +- 验证:个人报告 API 与入口聚焦测试 54/54 通过,静态回归锁定 route 使用 `.lte()`;聚焦 ESLint 和 `git diff --check` 通过。 +- 防复发:self-hosted route 不得调用 `LocalPostgresQueryBuilder` 未实现的 Supabase 操作符;报告回收合同继续锁定 `.lte()`,数据库集成层已有 `lte` 查询覆盖。 +- 相关记录:BUG-154 +- 复发自:BUG-154(staging 发布前遗漏 self-hosted 适配能力核对) +- 修复版本:本次报告接口修复提交 + +## BUG-157 | 侧栏“我的报告”按钮缺少 flex 布局导致图标与文字分离 + +- 状态:resolved(local candidate,待 staging gate/deployment) +- 首次发现:2026-08-10 +- 最近更新:2026-08-10 +- 影响面:展开状态的全局侧栏“我的报告”入口;导航行为、折叠态 tooltip 与 accessibility 语义不变。 +- 用户现象:报告图标贴在按钮左上方,文字单独位于中间,整体未与上方“新对话”按钮对齐。 +- 触发条件:侧栏展开并渲染 `.report-nav-button` 的图标与文字。 +- 根因:样式设置了 `justify-content` 和 `gap`,但没有启用 flex formatting context,因此这些对齐属性不生效。 +- 修复:直接复用相邻“新对话”按钮的原生 CSS 布局,补齐 `display:flex`、水平/垂直居中和一致的水平内边距。 +- 验证:个人报告入口回归锁定 `.report-nav-button` 的 flex、垂直居中和水平居中;聚焦测试、ESLint 与截图检查通过。 +- 防复发:带图标和文字的侧栏按钮必须在同一 flex formatting context 中对齐;入口合同测试持续锁定关键布局属性。 +- 相关记录:BUG-154 +- 复发自:无 +- 修复版本:本次侧栏对齐修复提交 + +## BUG-158 | 系统 Python 启动预检时重复使用不兼容解释器 + +- 状态:resolved +- 首次发现:2026-08-07 +- 最近更新:2026-08-07 +- 影响面:`scripts/pre_work_check.py`、开工预检、碎片扫描与聚焦治理测试 +- 用户现象:每次执行文档规定的 `python3 scripts/pre_work_check.py ...`,碎片扫描都会因 Python 3.9 不支持项目使用的 PEP 604 类型注解而退出,聚焦测试同时报 `/usr/bin/python3: No module named pytest`。 +- 触发条件:macOS 的裸 `python3` 指向 `/usr/bin/python3` 3.9,而仓库 `.venv` 已安装 Python 3.11 与 pytest。 +- 根因:预检脚本把启动自身的 `sys.executable` 固定为全部子命令的解释器,没有验证项目声明的 Python >=3.11,也没有优先使用仓库虚拟环境;`ERR-078` 原防线只要求操作者手动改用 `.venv`,因此文档中的标准命令仍会稳定复发。 +- 修复:预检入口按确定顺序探测项目 `.venv`、当前解释器和可用的 Python 3.11+;只有同时满足 Python >=3.11 与 pytest 可用才执行全部 Python 子检查。报告显式记录选择结果和各候选探测信息;找不到兼容环境时失败闭合并给出安装指引。新增严格的 `JYOTISH_PRE_WORK_PYTHON` 覆盖入口,配置错误时不静默退回其他解释器。 +- 验证:`tests/test_pre_work_check.py` 覆盖虚拟环境优先、系统 3.9 跳过、严格 override 和 runtime failure 状态;使用裸 `/usr/bin/python3 scripts/pre_work_check.py --remote-timeout 8 --command-timeout 45` 完整通过,报告选择仓库 `.venv/bin/python` 3.11 且 focused governance tests 通过。 +- 防复发:预检不得再直接用未经探测的 `sys.executable` 启动项目脚本或 pytest;标准文档命令必须纳入真实系统 Python 启动回归,且 JSON 报告必须保留 `python_runtime_ok` 和实际解释器信息。 +- 相关记录:ERR-011、ERR-014、ERR-078 +- 复发自:无 +- 修复版本:本次同步提交 diff --git a/docs/operations/production-server-migration-2026-08.md b/docs/operations/production-server-migration-2026-08.md new file mode 100644 index 00000000..71c745f7 --- /dev/null +++ b/docs/operations/production-server-migration-2026-08.md @@ -0,0 +1,236 @@ +# Production migration to 118.194.235.34 + +Status: **planned; do not change DNS or retire the old production yet**. + +This runbook moves production to the current reviewed `staging` release while also changing the persistence and identity layers: + +- old runtime: VPS + Supabase PostgreSQL + Supabase Auth; +- target runtime: `118.194.235.34` + private PostgreSQL 17 + Better Auth; +- user site: `https://jyotisha.chat`; +- admin site: `https://admin.jyotisha.chat`. + +This is not a volume copy. A full Supabase dump must not be restored over the target database. + +## Confirmed migration decisions + +- production Owner: `luna@copse.life` / `b8907d0c-6ed0-4270-b866-7e83bb4a1b26`; +- source Supabase project: `vtvnfqmonbfuxmqkqdlc`; +- the legacy production `ADMIN_EMAILS` allowlist contains only the designated Owner, and the legacy database has no `public.admin_users`; the ETL seeds that attested Owner into the target admin model; +- payment and model-provider ciphertext mode: `exclude`; re-enter both configurations after cutover with newly generated encryption keys; +- release policy: validate on `staging`, then promote the same accepted SHA to production; +- DNS remains unchanged until the final ETL and reconciliation pass; +- proposed maintenance window: `2026-08-12 02:00–04:00 UTC+8`, pending operator confirmation after rehearsal timing. + +## Release invariants + +The production deployment and schema-migration workflows are manual-only and accept a full lowercase 40-character `deploy_sha`. A normal production mutation proceeds only when all of the following identify that exact SHA: + +1. current `main`; +2. current `staging`; +3. a successful push-triggered `Staging Backend Quality Gate`; +4. a successful manually triggered `Jyotish Release Quality Gate`; +5. the public staging `/api/health` deployment identity. + +The deployment workflow consumes the exact API and Web image digests recorded by the staging gate. It does not build on the 2-core/4-GB production host, import user data, run schema migrations, or change DNS. The separate `Migrate Production Database` workflow uses the same gate-attested Web image only to run the schema checker/migrator/checker sequence; it does not run ETL, deploy the application, or change DNS. Both workflows share the `production-mutation` lock. + +## Required Gitea configuration + +Repository variables: + +| Name | Required value | +| --- | --- | +| `PRODUCTION_HOST` | `118.194.235.34` | +| `PRODUCTION_PORT` | `22` (confirmed on 2026-08-09) | +| `PRODUCTION_USER` | `deploy` | +| `PRODUCTION_PATH` | `/opt/jyotisha-production` | +| `PRODUCTION_URL` | `https://jyotisha.chat` | +| `PRODUCTION_ADMIN_URL` | `https://admin.jyotisha.chat` | +| `PRODUCTION_KNOWN_HOSTS` | Independently verified pinned host-key line | +| `STAGING_URL` | `https://staging.jyotisha.chat` | + +Repository secrets: + +- `PRODUCTION_SSH_PRIVATE_KEY`: the dedicated deploy private-key file encoded as one unwrapped base64 line; +- `REGISTRY_USERNAME` and `REGISTRY_PASSWORD`. + +Do not put the Ubuntu password, database URLs, Resend key, payment key, model-provider key, or encryption master keys in Gitea. The supplied bootstrap password must be rotated after an SSH key has been verified; it must never be committed or printed in a workflow. + +## New-server bootstrap + +Perform this interactively before any workflow dispatch: + +1. Patch Ubuntu and install Docker Engine, Compose v2, `rsync`, `curl`, `jq`, `flock`, and UFW. +2. Create a non-root `deploy` user, install a dedicated Ed25519 public key, and grant only the reviewed passwordless commands needed for Docker and deployment-tree ownership. +3. Verify a second key-only session and rotate the exposed bootstrap password. For this host, the production owner explicitly requires password authentication to remain enabled for other operators; do not change `PasswordAuthentication`. Workflows must still use the dedicated deploy key. +4. Permit only the confirmed SSH port plus `80/tcp`, `443/tcp`, and `443/udp`. Do not publish `3000`, `5200`, `5432`, or the Docker API. +5. Create a 2–4 GB swap file and enable Docker log rotation. Keep at least 15 GB free before the first image pull and database import. +6. Create `/opt/jyotisha-production`, owned by `deploy`, and preload the reviewed `postgres:17-alpine` and Caddy images. PostgreSQL image upgrades are separate maintenance operations. + +Create these host-only files with owner `deploy` and mode `0600`: + +```text +/opt/jyotisha-production/.env.production +/opt/jyotisha-production/.env.production.database +``` + +The application selectors must be exact: + +```dotenv +APP_ENV_FILE=../.env.production +CADDYFILE_PATH=./Caddyfile.production.selfhosted +SITE_ADDRESS=https://jyotisha.chat +AUTH_PROVIDER=self-hosted +SELF_HOSTED_IDENTITY_ENABLED=true +AUTH_USER_ORIGIN=https://jyotisha.chat +ADMIN_USER_ORIGIN=https://admin.jyotisha.chat +EPAY_CHAT_ENABLED=false +``` + +Use distinct production credentials for PostgreSQL roles, Better Auth, Resend, backup encryption, and dynamic rectification. Preserve the existing `EPAY_CONFIG_ENCRYPTION_KEY` and `MODEL_PROVIDER_CONFIG_ENCRYPTION_KEY` only through an approved secret-to-secret transfer. If either key cannot be transferred safely, exclude its ciphertext rows from import and re-enter those settings in the new admin UI. + +## Database migration engineering gate + +Before importing data, dispatch Gitea Actions → `Migrate Production Database` for the exact accepted release SHA. The workflow requires `main == staging == deploy_sha`, the same successful staging backend gate, the same manual release gate, and the public staging `/api/health` identity for that SHA. It also requires a non-sensitive recovery reference, its exact UTC creation time, and `restore_verified=true`; the recovery point must be no more than 24 hours old and must already have passed a restore verification. It verifies the current production revision, obtains the gate-attested immutable Web image, runs the schema checker, applies only pending application schema migrations, and requires the checker to converge afterward. + +Schema migration files are committed sequentially and are not one atomic transaction as a set. If a later file or post-check fails, earlier files may remain applied; stop, preserve evidence, and restore from the attested recovery point when repair-in-place is not explicitly reviewed. Do not assume a failed workflow means the database is unchanged. + +The production database must already grant `migration_runner` membership in `schema_owner`; `deploy/postgres/001-bootstrap-roles.sh` grants only that migration role the ability to `SET ROLE schema_owner`. Identity, app, service, admin, and backup runtime roles must not receive this membership. The workflow checks the membership and refuses to add it itself. + +The reviewed data-transfer entry point is `frontend/scripts/migrate-supabase-production.mjs`. It has separate `--preflight`, `--apply`, and `--verify` modes; the application deployment workflow never runs it automatically. Production cutover remains blocked until the exact production snapshot has completed an isolated rehearsal and final verification. + +The tool must: + +- connect to the source using `REPEATABLE READ READ ONLY`; +- refuse a non-empty target business database; +- use explicit columns and dependency order, never `SELECT *`; +- preserve all user UUIDs and transactional primary keys; +- import Supabase `auth.users` into Better Auth without passwords, sessions, JWTs, provider tokens, or MFA secrets; +- map `banned_until`, or emit an explicit blocked-user reconciliation manifest; +- merge seed/configuration records by natural key rather than copying target-generated IDs; +- map active administrators to canonical target role codes and require at least one explicit Owner; +- run post-import reconciliation for legacy billing and retired birth-time rectification rows; +- emit only counts, state aggregates, and normalized SHA-256 manifests—not email addresses, birth data, tokens, or connection strings; +- run all target writes in a transaction and roll back on failure. + +The operator supplies these values only on the trusted migration host; do not store the database URLs or encryption keys in Gitea: + +- `SUPABASE_SOURCE_DATABASE_URL`: the consistent read-only Supabase snapshot/source URL; +- `PRODUCTION_TARGET_DATABASE_URL`: the PostgreSQL 17 target URL using the migration role; +- `PRODUCTION_OWNER_USER_ID`: the UUID of the designated active source administrator; +- `PRODUCTION_OWNER_EMAIL`: the canonical email that must match that UUID in source `auth.users`; +- `PRODUCTION_CIPHERTEXT_MODE=preserve|exclude`; preserve additionally requires `PRODUCTION_CIPHERTEXT_KEYS_CONFIRMED=true`. + +Run each phase separately and retain its redacted JSON manifest: + +```bash +cd frontend +node scripts/migrate-supabase-production.mjs --preflight +node scripts/migrate-supabase-production.mjs --apply +node scripts/migrate-supabase-production.mjs --verify +``` + +`--apply` is intentionally one-shot: it refuses a populated target. If apply fails, discard or restore the isolated target, correct the cause, and rerun from an empty migrated schema rather than improvising a partial resume. + +Do not import platform schemas, source roles/grants, Supabase migration ledgers, sessions, refresh tokens, or provider tokens. Do not use a full-database `pg_restore` against the target. + +Because migrated users have no portable password/session, all sessions are invalidated and users sign in again through email OTP. Administrators re-enrol MFA. + +## Rehearsal + +On 2026-08-09, an isolated PostgreSQL 17 rehearsal completed `--preflight`, `--apply`, and `--verify` against a read-only production Supabase transaction. It reconciled 85 identities and all 18 selected non-empty/seeded legacy public tables by count, primary-key hash, normalized row hash, credit totals, consultation states, and rectification counts. This is rehearsal evidence only; it does not authorize the final write freeze, production import, or DNS change. + +Complete the remaining runtime and restore rehearsal before scheduling the final window: + +1. Apply all target schema migrations to an empty rehearsal database using the same schema migrator path as `Migrate Production Database`. +2. Run migration preflight, apply, post-import reconciliation, and verify. +3. Verify source/target row counts, primary-key set hashes, normalized row hashes, credit totals, payment state totals, subscriptions, reports, consultations, and rectification records. +4. Verify one Owner exists, every active admin has a target role, and database roles remain isolated. +5. Test OTP login, historical balance and history reads, admin login/RBAC, report generation/export, payment callback rejection/idempotency, and model provider access. +6. Create an encrypted backup, restore it into a separate database, and repeat smoke checks. +7. Record the observed export/import/verification duration and use it to set the maintenance window. + +For the 2-core/4-GB host, keep database pools bounded (recommended starting maxima: identity 5, app 5, admin 3) rather than allowing three pools of 10 to consume all 30 PostgreSQL connections. + +## DNS preparation + +At least one current TTL period before cutover, reduce relevant Spaceship TTLs to `300`. Do not change record targets yet. Check both authoritative nameservers and remove any legacy `AAAA` record that points elsewhere. + +Final records are: + +| Type | Host | Value | +| --- | --- | --- | +| `A` | `@` | `118.194.235.34` | +| `A` | `admin` | `118.194.235.34` | +| `CNAME` | `www` | `jyotisha.chat` | + +Both `jyotisha.chat` and `admin.jyotisha.chat` are required. The application rejects unknown identity hosts, and the user domain intentionally hides `/admin` and `/api/admin/*`. + +## Cutover sequence + +### T-24 hours + +- Confirm the exact release SHA is deployed and accepted on staging. +- Run the manual release quality gate for that SHA. +- Confirm final backup capacity, restore rehearsal, SMTP/OTP delivery, and rollback contacts. +- Create and restore-verify a production recovery point no more than 24 hours before the schema migration; record its non-sensitive reference and UTC creation time. +- Dispatch `Migrate Production Database` for the accepted SHA with that recovery attestation and confirm its post-check reports no pending schema migrations. +- Record pending payment orders and long-running jobs; choose an explicit disposition for each. +- Dispatch `Deploy production` with `verification_mode=internal` only after target schema/data preparation. This verifies the new host without depending on public DNS. + +### Maintenance freeze + +1. Set payment/package creation off and keep `EPAY_CHAT_ENABLED=false`. +2. Put old production in maintenance mode and stop Web/Agent/background writes. +3. Disable new Supabase registrations for the window. +4. Confirm source row counts stop changing. +5. Take the final encrypted source backup and one consistent source snapshot. +6. Run the production ETL once, then post-import reconciliation and verification. +7. Verify role-specific `SELECT 1` connectivity for identity, app, service, and admin roles; verify permission isolation separately. +8. Keep the new site in maintenance mode while running user/admin/report/payment smoke checks. + +Do not attempt an ad-hoc full-plus-incremental migration. Several tables lack a common `updated_at` or soft-delete contract, so an improvised delta can lose deletes, refunds, or accounting changes. + +### DNS and public verification + +1. Change the three Spaceship records only after all final data assertions pass. +2. Verify both authoritative nameservers, then public recursive resolvers. +3. Wait for Caddy certificates for both user and admin hosts. +4. Dispatch the same exact SHA with `verification_mode=public`. +5. Verify OTP login, logged-out account `401`, user-host admin paths `404`, admin-host unauthenticated behavior, health SHA, report generation/export, and one controlled payment callback test. +6. Re-enable public writes. Re-enable payment only after DNS convergence and callback verification. + +## Go/no-go assertions + +Cutover is **no-go** if any of these conditions is true: + +- `main`, `staging`, staging health, gate artifact, or requested SHA differs; +- any migration or manifest checksum is unresolved; +- source/target identity, balance, order, subscription, or report reconciliation differs; +- there is no active Owner or an active admin has no role; +- migrated banned users are not accounted for; +- a production encryption key/ciphertext decision is unresolved; +- backup restore has not been demonstrated; +- role-specific database readiness or isolation fails; +- either user/admin TLS host is unavailable; +- pending payment writes or callbacks can still reach the old writable database. + +## Rollback boundary + +Before the new database accepts real writes, rollback is: restore old DNS targets, keep the old site/Supabase authoritative, and investigate the isolated target. + +After the new database accepts real writes, a DNS-only rollback is unsafe. First stop new writes, reconcile the new PostgreSQL delta back to the chosen authority, and obtain an explicit operator decision. Otherwise post-cutover users, orders, credits, and reports can be lost. + +Keep the old VPS in maintenance/read-only mode for at least 7–14 days and retain Supabase for 14–30 days. Do not destroy either immediately after DNS cutover. + +## Workflow dispatch + +Normal release: + +1. Merge the reviewed `staging` release into `main` so both heads are the same SHA. +2. Confirm the staging push gate, public staging SHA, and manual release gate all succeeded for that SHA. +3. Open Gitea Actions → `Migrate Production Database`; enter the exact 40-character SHA, the no-more-than-24-hour-old recovery reference and UTC creation time, and confirm `restore_verified=true`. Wait for the post-migration checker to converge. Do not use this workflow for Supabase ETL. +4. Run the trusted-host ETL phases and retain the redacted reconciliation manifests. +5. Open Gitea Actions → `Deploy production`. +6. Enter the same exact SHA, leave `allow_rollback=false`, and choose `internal` or `public` for the current cutover phase. + +Application rollback accepts only an explicitly authorized, previously gate-attested SHA in reviewed `main` history. Database migrations and imported data are not rolled back by the application workflow. diff --git a/docs/research/birth_time_rectification_v4_design_2026_07_26.md b/docs/research/birth_time_rectification_v4_design_2026_07_26.md deleted file mode 100644 index 468d6fa9..00000000 --- a/docs/research/birth_time_rectification_v4_design_2026_07_26.md +++ /dev/null @@ -1,383 +0,0 @@ -# 生时校正 V4 重构设计 — 2026-07-26 - -## 1. 结论 - -生时校正不应继续作为“聊天接口里顺便跑一次模型和分钟扫描”的功能维护。V4 将它重构为独立、可恢复、可审计的证据工作流: - -1. 先收集跨领域、带日期精度的人生事件; -2. 再对已经存在但日期较粗的事件做定向修订; -3. 后台 Worker 对冻结的计算口径和证据集合评分; -4. 只输出通过稳定性门槛的候选时间范围; -5. 用户主动保存后,才把该范围交给原咨询问题继续使用。 - -产品永久边界:**V4 不确认单分钟,不把峰值分钟显示为真实出生时间,不修改 `profiles.active_birth_time`。** - -本文记录已经落地到本地可测试工作树的目标架构,而不是对旧实现的小修补。 - -## 2. 交互记录暴露的旧架构问题 - -用户提供的 Agent 交互记录说明,专业校时需要的不只是一个聊天框,而是一套持续数十轮仍保持一致的证据系统。旧 Web 流程无法稳定复现该过程,主要原因不是文案不够像 Agent,而是职责没有拆开。 - -### 2.1 预设年份问卷替代了真实证据 - -旧流程先给出宽年份选项,再不断要求把同一事件从年份段缩到年份、季度、月份。这样会产生三个问题: - -- 问题本身暗示事件应该发生在哪个窗口,带来确认偏差; -- 用户已经给过事件后,系统仍可能把它当成下一条新事件; -- 为了进入评分而取区间中点,会制造不存在的日期精度。 - -V4 改为先接收用户自己声明的事件和日期,再把后续日期补充写成同一事件的新 revision。 - -### 2.2 对话、抽取、评分和结果表达在同一请求中耦合 - -旧流程把事件抽取、技术计算、叙事生成和下一题规划放在一个请求生命周期里。任一模型超时、跨语言 schema 漂移或计算耗时,都可能让已提交的经历看起来没有保存。 - -V4 的同步请求只负责持久化答案并创建 Job;Worker 异步完成抽取、评分、稳定性检查和下一题规划。用户提交成功后可以离开页面,稍后继续。 - -### 2.3 “第一名分钟”被误当成产品答案 - -分钟扫描必然会产生一个最高分,但最高分不等于真实出生分钟。旧交互即使同时声明低置信度,仍会把一个具体分钟写成“建议暂用时间”,视觉上压过候选范围和不确定性说明。 - -V4 将 `representativeTime` 限定为内部聚类数据。公开快照中的 `canConfirmExactMinute` 是字面量 `false`;UI 只显示 `startTime–endTime`,不显示峰值分钟。 - -### 2.4 没有稳定的事件身份和修订链 - -用户可能先说“某年发生”,后续补成“某年某月”,也可能纠正原先年份。覆盖旧记录会丢失审计信息;新建一条记录又会重复计分。 - -V4 使用 `eventId + revision`: - -- `eventId` 表示同一人生事件; -- 每次补充创建 append-only revision; -- `supersedesRevisionId` 指向上一版本; -- 评分只读取每个 `eventId` 的最新 revision。 - -### 2.5 会话状态不足以承担业务状态 - -浏览器会话可能被删除、刷新或从另一设备继续。旧流程把关键进度附着在聊天消息上,难以保证幂等、恢复、并发提交、扣费和原问题 handoff。 - -V4 将 Case、Turn、Event Revision、Snapshot、Job 和 Handoff 独立持久化;聊天会话只是入口和展示容器,不再是生时校正业务真源。 - -## 3. 产品合同 - -### 3.1 输入 - -- 已保存出生日期; -- 用户声明的候选时间和不确定范围; -- 出生地点坐标与时区; -- 固定计算口径:Lahiri、Mean Node、一分钟步长; -- 用户主动提供的人生事件。 - -### 3.2 证据领域 - -| 领域 | 典型事件 | 评分状态 | -| --- | --- | --- | -| `education` | 升学、复读、毕业、转学 | scoreable | -| `relocation` | 搬家、长期迁居、离乡 | scoreable | -| `relationship` | 重要关系开始或结束 | scoreable | -| `career` | 入职、离职、转行、职责突变 | scoreable | -| `finance` | 收入、负债、资产明显变化 | scoreable | -| `health_pressure` | 疾病、手术、事故、长期压力起点 | scoreable | -| `family` | 家庭结构或亲属重大事件 | context-only | -| `other` | 其他明确、重要、可核对事件 | 按领域能力决定 | - -家庭事件先保留为上下文,不应为了“凑够领域”接入没有可靠 scorer 的技术层。 - -### 3.3 输出 - -- 一个主要候选范围; -- 可选的次级候选范围; -- 支持该范围的事件; -- 冲突或区分力不足的事件; -- 稳定性门结果; -- 用户是否已主动保存该范围。 - -### 3.4 永久禁止 - -- 不确认单分钟; -- 不把 `representativeTime` 作为公开结论; -- 不自动保存候选范围; -- 不修改 `profiles.active_birth_time`; -- 不因聊天文案或模型失败丢弃已经持久化的答案; -- 不用区间中点伪造事件日期; -- 不把同一事件的日期补充重复计分。 - -## 4. 两阶段问题规划 - -### 4.1 阶段一:领域覆盖 - -按低回忆成本优先收集七个领域。每次只问一个开放问题,要求用户自己提供事件与尽可能准确的年月,不先展示系统猜测的年份窗口。 - -当前确定性顺序为: - -```text -education → relocation → relationship → career → finance → health_pressure → family -``` - -规划器已经保留 `candidateSplitByDomain` 输入;未来只有当评分引擎能给出可解释的领域区分力时,才允许在不增加模型自由度的前提下动态排序。 - -### 4.2 阶段二:日期精度修订 - -完成领域覆盖后,对最新 revision 仍不是 `day` 精度的 scoreable 事件定向追问: - -- 问题保存 `targetEventId`; -- Turn 保存 `questionTargetEventId`; -- Worker 用该 ID 将回答追加到原事件; -- 如果回答没有可解析日期,不创建伪 revision; -- 已经追问过或用户跳过的事件写入尝试集合,不循环追问。 - -如果所有可修订事件都已处理但仍未通过稳定性门,则询问新的、日期明确的重要事件;用户可以暂停或结束。 - -## 5. 状态机 - -### 5.1 Case 状态 - -```mermaid -stateDiagram-v2 - [*] --> awaiting_answer: create case - awaiting_answer --> processing: submit answer - processing --> awaiting_answer: worker plans next question - processing --> range_ready: range passes gate - awaiting_answer --> paused: pause - paused --> awaiting_answer: resume - awaiting_answer --> abandoned: abandon - range_ready --> abandoned: abandon without save - range_ready --> range_ready: explicitly save accepted range -``` - -| 状态 | 含义 | `currentQuestion` | -| --- | --- | --- | -| `awaiting_answer` | 等待用户回答 | 必须非空 | -| `processing` | 答案已保存,后台处理中 | 空 | -| `range_ready` | 有候选范围;可能尚未保存 | 可空;稳定范围就绪时为空 | -| `paused` | 用户主动暂停 | 保留可恢复进度 | -| `abandoned` | 用户结束本次校正 | 空 | - -关键不变量:证据不足时必须生成下一题,不能出现 `status=awaiting_answer` 且 `currentQuestion=null`。 - -### 5.2 Worker 阶段 - -```text -extracting_evidence - → scoring_candidates - → checking_robustness - → planning_question - → collecting_evidence | complete -``` - -Case 状态描述用户能做什么,Phase 描述系统正在做什么。两者不能混用。 - -### 5.3 Job 状态 - -```text -pending → processing → completed - └→ failed -pending/expired processing → processing by another worker -obsolete input → stale -``` - -Job 使用十分钟 lease。只有持有有效 lease 且输入 case version、证据 hash、计算口径 hash 仍匹配的 Worker 可以完成任务。 - -### 5.4 Handoff 状态 - -```text -pending → claimed → executing → consumed -``` - -过期的 `claimed/executing` lease 可恢复为 `pending`。Handoff 使用 `requestId`、`claimActionId` 和 settlement receipt 保证多设备重试不会重复执行或重复扣费。 - -## 6. 数据模型 - -### 6.1 Case - -Case 冻结一次校正的业务口径: - -- `calculationSpec`; -- `calculationSpecHash`; -- `evidenceSetHash`; -- `version`; -- `currentQuestion`; -- `latestSnapshot`; -- `acceptedRange`。 - -`acceptedRange` 在用户点击“保存这个范围”前始终为 `null`。 - -### 6.2 Turn - -Turn 是用户回答的不可变输入记录: - -- 本轮问题 ID、领域和目标事件 ID; -- 可见问题文本; -- 用户原始回答; -- `actionId`; -- 提交时的 case version。 - -失败重试时可以恢复同一个问题,不需要从聊天文本反向猜测当时问了什么。 - -### 6.3 Event 与 Event Revision - -Event 提供稳定身份;Revision 保存领域、事件类型、摘要、原文、日期范围、精度和评分资格。 - -评分前执行 `latestEventRevisions()`,确保同一事件只使用最新版本。原始回答保留,技术评分不依赖经过润色的叙事文案。 - -### 6.4 Candidate Snapshot - -每次评分都生成不可变 Snapshot: - -- 输入证据 hash; -- 计算口径 hash; -- 算法版本; -- 全部分钟候选分数; -- 候选聚类; -- 稳定性结果; -- 决策门原因。 - -Snapshot 使刷新、复算和算法升级可审计,不用覆盖上一轮结果。 - -## 7. 候选聚类与稳定性门 - -### 7.1 聚类 - -当前算法取峰值分数的相对 `0.97` 以上候选,将相邻分钟合并为 cluster,并按峰值和 score mass 排序。 - -`representativeTime` 仅用于内部描述 cluster 峰值。公开结果使用 `startTime` 和 `endTime`。 - -### 7.2 可保存范围门槛 - -主要范围必须同时满足: - -- 至少 5 个 scoreable 事件; -- 至少 3 个 scoreable 领域; -- cluster 宽度至少 2 分钟,拒绝单分钟结果; -- cluster 宽度不超过 15 分钟; -- 邻近分钟支持至少 2 分钟; -- leave-one-out 保留率至少 0.8; -- 日期敏感性保留率至少 0.8; -- 计算口径 hash 与建案时一致。 - -无论是否通过,`canConfirmExactMinute` 永远为 `false`。 - -## 8. 请求与后台执行边界 - -### 8.1 同步 API 负责 - -- 认证和输入校验; -- 幂等 action; -- optimistic case version 检查; -- 保存 Turn; -- 创建 pending Job; -- 返回 `202` 和可轮询 Job。 - -### 8.2 Worker 负责 - -- 抽取新事件或定向修订; -- 计算 evidence hash; -- 调用 Python 候选引擎; -- 聚类与稳定性门; -- 生成 Snapshot; -- 规划下一题; -- 原子完成 Job 和 Case 状态迁移。 - -非关键叙事模型不在 V4 完成链路上。确定性问题和业务状态在模型不可用时仍可继续。 - -## 9. 数据库不变量 - -迁移 `20260726020000_birth_time_rectification_v4.sql` 将关键规则下沉到 PostgreSQL: - -1. 同一用户最多一个未结束、未接受范围的活动 Case; -2. `actionId` 幂等,同一 action 不能绑定不同问题; -3. `expectedCaseVersion` 不匹配时拒绝陈旧写入; -4. 未通过快照门或范围与最新主要 cluster 不一致时不能保存; -5. 有效 Worker lease 不能被抢占,过期 lease 可以接管; -6. Worker 只能完成仍匹配输入 hash 和 version 的 Job; -7. Handoff claim、begin、refund 和 settlement 可重试; -8. 一次 Handoff 最多产生一次成功扣费; -9. 取消的请求可以重新生成 request key; -10. 整个 V4 migration 不更新 `profiles.active_birth_time`。 - -## 10. UI 设计 - -### 10.1 首屏 - -必须同时告诉用户: - -- 正在比较的声明候选边界; -- 当前流程先核对经历; -- 结果只会是候选范围,不是已确认分钟; -- 原咨询问题已保留。 - -### 10.2 处理中 - -提交后立即显示“回答已经保存,计算在后台继续”。允许用户离开页面,不用让浏览器请求一直等待。 - -### 10.3 日期修订 - -日期修订问题必须有可见 label 和输入框,不得只显示空卡片。问题明确允许“不记得/跳过”,避免为了通过流程而编造日期。 - -### 10.4 结果 - -只显示: - -- 主要/次级候选范围; -- 支持经历; -- 冲突或区分力不足; -- 不确定性说明; -- “保存这个范围”操作。 - -不显示内部事件 ID、hash、技术 packet、模型错误、评分明细或峰值分钟。 - -### 10.5 保存与继续咨询 - -点击“保存这个范围”只写 `acceptedRange`。之后用户可以把该范围带回原问题;咨询服务将其作为“未验证候选范围”使用,而不是已确认出生时间。 - -## 11. 错误与恢复策略 - -- 输入不合法:同步返回稳定中文错误,不创建 Job; -- 陈旧版本:返回冲突,客户端刷新 Case; -- Worker 失败:Job 标记 failed,恢复原问题; -- Worker 崩溃:lease 到期后其他 Worker 接管; -- 证据不足:生成下一题,不伪装成技术异常; -- 用户暂停:保留 Case、事件和问题; -- 用户结束:Case 进入 abandoned,出生资料不改写; -- 页面刷新:从活动 Case、Job 和事件台账恢复; -- 多设备 handoff:由数据库 lease 和 settlement receipt 仲裁。 - -## 12. 已落地代码边界 - -```text -frontend/src/lib/rectification-v4/ 领域模型、规划、评分适配、Store、Worker -frontend/src/app/api/rectification/v4/ HTTP API -frontend/src/components/rectification-v4-panel.tsx -frontend/src/hooks/use-rectification-v4.ts -frontend/scripts/rectification-v4-worker.mts -scripts/active_rectification_events_v4.py -frontend/supabase/migrations/20260726020000_birth_time_rectification_v4.sql -frontend/tests/rectification-v4-*.test.ts -tests/test_active_rectification_events_v4.py -``` - -首页旧聊天入口继续保留外壳和历史兼容,但新的活动流程由 `RectificationV4Panel` 和 V4 API 驱动。 - -## 13. 本地验收结果 - -截至 2026-07-26: - -- 前端 V4、handoff、replay、consultation continuation:33 个测试通过; -- Python 引擎:2 个测试通过; -- 目标 ESLint:通过; -- `npm run build -- --webpack`:通过,27 个页面生成成功; -- PostgreSQL:12 项迁移和并发/扣费不变量通过; -- 真实 PostgreSQL + Python Engine E2E:最终进入 `range_ready`,主要范围 `05:26–05:30`; -- E2E 后 `active_birth_time` 保持原值,`acceptedRange` 保持空值; -- 静态浏览器预览:日期修订输入框、范围保存按钮、不确认分钟文案和 390px 无横向溢出均通过。 - -本轮没有提交、推送或部署。由于当前本地认证浏览器与隔离 V4 数据库/服务环境没有安全地连在一起,尚未声称“真实登录态端到端 UI”已验收;发布前仍需在正确的本地或 staging 认证环境执行一次完整用户操作 smoke。 - -## 14. 发布前必须补齐 - -1. 应用迁移并核对 migration ledger; -2. 启动独立 Worker,确认部署环境包含数据库和 Python API 配置; -3. 用测试账户完成:建案 → 七领域 → 日期修订 → 范围就绪 → 主动保存 → 原问题 handoff; -4. 证明刷新和另一设备可恢复; -5. 证明重复提交、过期版本和 Worker 接管不重复事件、不重复扣费; -6. 再次核对 `profiles.active_birth_time` 未被 V4 路径更新; -7. 将验收绑定到精确部署 Git SHA,而不是只看 HTTP 200。 diff --git a/docs/research/conversational_minute_rectification_change_plan_2026_07_22.md b/docs/research/conversational_minute_rectification_change_plan_2026_07_22.md deleted file mode 100644 index 258e276a..00000000 --- a/docs/research/conversational_minute_rectification_change_plan_2026_07_22.md +++ /dev/null @@ -1,131 +0,0 @@ -# 对话式生时纠正修改方案与真实回放门槛 - -日期:2026-07-22 - -## 结论先行 - -当前系统可以改成普通 session 式的一问一答,但不能把“交互更顺”描述成“已经能准确到分钟”。现有公开开发案例的分钟评分仍不稳定;产品入口还会丢弃健康、事故、家庭类证据,并且实际只把最近 6 条事件送入评分。 - -本方案把修改拆成两个互不混淆的目标: - -1. 让用户通过自然语言持续补充经历,系统每轮只问一个最有区分力的问题。 -2. 用开发集和独立冻结盲测证明分钟排序是否真的收敛;没有通过门槛时只返回候选范围和证据缺口。 - -## 已确认的问题 - -### 1. 首轮正文被技术回执合同绑架 - -`narrative-agent.ts` 当前要求首轮正文复述所有稳定层、敏感层、具体值和领域映射。模型未满足时,fallback 又会输出同一份 D 层清单。技术包本身可以保留,但不应该成为用户正文。 - -### 2. 自然语言证据域不完整 - -`evidence-extractor.ts` 只显式识别事业、学业、迁移、关系、家庭、财富。疾病、手术、事故和丧亲会落到 `other`;`route.ts` 又会排除 `family` 和 `other`,所以 D30、家庭与部分重大人生事件无法参与评分。 - -日期正则还被限制为 1900–2099 年。公开历史人物案例中的 18xx 年事件会被保存成“日期未知”,无法评分;这也意味着现有历史 AA 开发案例并没有真正通过当前产品的自然语言入口。 - -### 3. 事件数量合同互相矛盾 - -- 会话收敛层:3 条开始排序,8 条停止继续取证。 -- 路由评分层:只保留最近 6 条。 -- Jyotish Skill:至少 5 条 dated events 才适合定框;高置信度通常需要 10–15 条多领域事件。 - -3 条可以产生早期候选反馈,但不得升级成准确分钟。停止条件也不能仅由事件数量触发。 - -### 4. 当前问题选择仍偏“让用户交材料” - -系统应从候选差异中选择一个信息增益最高、用户容易回忆的问题,例如“第一次正式工作是哪年哪月”,而不是一次要求用户提交多个领域的事件。 - -## 最小实施方案 - -### 阶段 A:修正文合同,不改评分器 - -建议改动: - -- `frontend/src/lib/conversational-rectification/narrative-agent.ts` - - 首轮正文只要求:当前范围、尚未确认边界、证据进度、一条具体问题。 - - 取消首轮必须公开全部 stable/sensitive layer values 的校验。 - - fallback 同样只输出一条问题。 -- 技术层继续完整保存在 technical packet / receipt 中,在 UI 中放入可折叠“计算依据”。 -- 用户消息区、输入框、滚动和留白完全复用普通 session 布局,不保留卡片式问卷。 - -验收:首轮正文不出现 D1/D9/D10 技术 dump;每轮只有一个问号和一个待回答主题。 - -### 阶段 B:补齐事件入口 - -建议改动: - -- 在持久化和技术包域中新增 `health_pressure`,不要把它降级成 `other`。 -- 抽取器识别:确诊、住院、手术、事故、受伤、创伤、重大疾病、康复。 -- 日期解析支持出生之后的合法公历年份,而不是写死 19xx/20xx;继续用 `asOfDate` 排除未来事件。 -- 家庭事件不要整体丢弃;按父母、子女、生育、丧亲等映射到后端支持的主题证据,若评分器尚无对应合同则明确存储为 context-only,而不是静默消失。 -- 移除 `.slice(-6)` 的隐式截断。建立一个共享常量;建议开发回放先测 5、8、10、12 条,再决定产品上限。 -- UI 显示“已记录 / 可评分 / 需补日期 / 仅作背景”的数量,避免用户以为回答已评分。 - -验收:真实测试集中所有标记为可评分的自然语言回答都能保留正确日期、精度和领域;任何被排除的事件都有可见原因。 - -### 阶段 C:一次问一个高信息量问题 - -复用现有 `candidateDifferences`、`suggestedDomains` 和 fact difference opportunities: - -1. 过滤已经问过、用户表示不记得、或缺少后端支持的主题。 -2. 优先选择能把当前候选分成最均衡分区的事件领域。 -3. 将领域转成生活化问题,并要求“年份/月份 + 发生了什么”。 -4. 用户回答后重新排序,再选择下一题。 - -问题模板必须具体,例如: - -> 你第一次明显的工作转折发生在什么时候?可以是入职、离职、换行业、升职或创业。直接告诉我大约哪一年哪一月和发生了什么;记不清月份也可以先说年份。 - -### 阶段 D:安全收敛 - -每轮都可以展示“当前领先范围”,但只有同时满足以下条件才允许提示确认: - -- 至少 5 条可评分事件,且至少 3 个领域;高置信度目标使用 10–15 条。 -- 真实候选形成唯一分钟或预先规定的窄范围。 -- ±1/2/5 分钟邻近稳定性通过。 -- leave-one-event-out 通过,不能由单一事件决定结果。 -- 没有缺失的强制计算层。 -- 独立冻结盲测达到发布指标。 - -未满足时输出:候选范围、支持事件、冲突事件、未评分事件和下一条问题,不允许写“准确时间为”。 - -## 测试集分层 - -### 对话开发回放集 - -文件:`references/real_case_calibration/conversational_rectification_development_v1.json` - -当前 v1 是入口 smoke fixture,不是分钟收敛 benchmark:TypeScript 测试验证 fixture 中自然语言在现有抽取器下的日期、精度和领域;Python 回放使用同一 fixture 的人工 `expected_route_scoreable` 标签模拟现有路由过滤,再测试逐轮排名。它不是自然语言到评分的端到端回放,且每例不足 5 条可评分事件,不能用于判断 Skill 级收敛。 - -下一版 development benchmark 必须把每例扩充至至少 5 条、目标 10–15 条多领域事件,并直接调用抽取器和路由投影,覆盖记不清、拒答、否定、纠正、重复和一条消息多事件。它引用仓库已有公开 AA 开发案例,永久排除在 holdout 之外,可以用于修 bug 和调问题排序。 - -### 独立冻结 holdout v4 - -开发集不能证明发布准确率。正式验证需要另一位 reviewer 收集至少 20 个未参与调参的公开 AA 案例,在评分前完成:出生来源复核、事件来源复核、假分钟承诺、manifest hash、实现 hash 和真值密封。只允许盲回放一次;失败后整批降为 development,下一版重新收集 fresh holdout。 - -## 指标 - -对话入口: - -- 日期和精度提取准确率。 -- 领域提取准确率。 -- 应评分事件保留率。 -- 每轮问题数必须等于 1。 -- 不能回答、记不清、纠正旧答案时仍能继续。 - -分钟能力: - -- 3、5、8、10 条事件时的 Top-1 / Top-3。 -- 逐轮真实分钟排名和误差轨迹。 -- 唯一分钟率、邻近稳定性、leave-one-out。并列范围未破时 `predicted_time` 必须为空;如保留确定性 tie-break,只能单列为诊断 MAE,不能当成分钟能力。 -- false confirmation rate 和 confirmation coverage 必须同时报告。 - -建议继续沿用当前发布门槛:Top-1 ≥ 60%,Top-3 ≥ 85%,MAE ≤ 2 分钟,false confirmation ≤ 5%,insufficient-evidence rejection ≥ 90%。confirmation coverage 不能为 0,否则只是“永不确认”而非可用收敛。 - -## 实施顺序 - -1. 先合并阶段 A 与入口可观测性,不改变“准确分钟”声明。 -2. 用本开发回放集修阶段 B,确保事件没有在入口丢失。 -3. 接入阶段 C 的单问题排序并做多轮 replay。 -4. 当开发集达到稳定目标后,冻结实现并收集 fresh holdout v4。 -5. 只有 holdout 通过后才开放分钟确认;否则产品始终停在候选范围。 diff --git a/docs/research/pre_work_error_ledger.md b/docs/research/pre_work_error_ledger.md index d4d50769..c3167163 100644 --- a/docs/research/pre_work_error_ledger.md +++ b/docs/research/pre_work_error_ledger.md @@ -251,3 +251,15 @@ Prevention: keep evidence-request, life-event, private-candidate, public-recap, After accumulated historical evidence produced a very narrow winning segment, that segment could contain fewer than two linked samples or discriminating divisional themes. Packet construction treated this valid “not enough distinction yet” state as a dependency failure, so a later answer returned 503 even though scoring and the astrology service were healthy. Prevention: classify insufficient candidate-range discrimination explicitly; when a newly narrowed segment cannot support the technical evidence contract, retain the prior candidate range, preserve scored evidence, clear the unconfirmed result, and continue conversational collection. + +## ERR-102 | Gitea returned HTTP 502 during final remote synchronization check | active 2026-08-09 + +Two final `git fetch origin --prune` attempts against the configured primary Gitea remote failed before ref exchange with HTTP `502`. The last locally verified refs remain available, but this run cannot prove that they are still current and must not claim a completed remote synchronization or push. + +Prevention: retry fetch and `git ls-remote` before any push or release action, compare the full `main`, `staging`, and migration-branch SHAs, and stop if Gitea remains unavailable. Do not substitute cached refs, the GitHub mirror, or a successful local commit for current Gitea synchronization evidence. + +## ERR-103 | Release Gate runner 缺少 Docker Compose v2 导致 17 个集成测试级联失败 | mitigated 2026-08-09 + +手工 Release Gate Run `1638` 在 `xiaoxin` 上通过 Docker Engine 检查后进入完整测试,但该 runner 不支持 `docker compose`,`--project-name` 与 `--env-file` 被 Docker 顶层 CLI 判为 unknown flag,导致 17 个 PostgreSQL、备份、身份和权限集成测试级联失败。候选 staging SHA、旧生产、Supabase 与 DNS 未被改变。 + +Prevention: 将该门禁运行在已验证 Docker Compose v2 的 `manman-linux`,并在安装依赖前以 `docker compose version --short` 强制 v2;不得把 `docker version` 当作 Compose 能力证明。新 SHA 必须重新完成 staging gate、公网 staging 身份和手工 Release Gate,失败 run 不得授权生产 migration、deploy 或维护停写。 diff --git a/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md b/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md deleted file mode 100644 index f2fa11d1..00000000 --- a/docs/research/user_reported_birth_time_flow_issues_2026_07_20.md +++ /dev/null @@ -1,57 +0,0 @@ -# User-reported birth-time flow issues — 2026-07-20 - -This ledger records the five reported product failures and the evidence needed -to close them. It contains no copied authentication header, cookie, token, -email, user UUID, or real birth record. The original plan described this file as -an existing modification target; it did not exist in this checkout, so Task 12 -created it. - -`verified-local` means deterministic contract tests and the equivalent local -PostgreSQL 14 workflow pass. It is deliberately not `closed`: closure also -requires an authenticated synthetic production smoke whose health Git SHA is -the tested deployment SHA. - -| Issue | Reported failure | Current status | Local evidence | Production closure artifact | -| --- | --- | --- | --- | --- | -| ISSUE-BT-001 | A chat appeared impossible to delete, or a late response could recreate it. | verified-local | `20260720000000_chat_delete_and_dynamic_candidate_confirmation.sql`; `frontend/tests/chat-session-delete-contract.test.ts`; the PG14 full-flow test deletes a real RLS-owned chat as `authenticated` and proves the account case remains. | Authenticated synthetic delete plus account-case reload, tied to `/api/health` deployment SHA. | -| ISSUE-BT-002 | A new chat could not establish a fresh rectification interaction and unfinished progress was coupled to chat state. | verified-local | `20260720010000_conversational_rectification_schema.sql`; account-level resume in `frontend/tests/conversational-rectification-e2e.test.ts` across two route clients; Task 9 current-chat consent tests. | Authenticated new-device/new-chat resume smoke tied to the deployed SHA. | -| ISSUE-BT-003 | Confirming a candidate such as `17:15` surfaced `The string did not match the expected pattern`. | verified-local | Atomic v3 confirmation in `20260720030000_conversational_rectification_transitions.sql`; client retry/fallback and mismatched-then-exact confirmation in `frontend/tests/conversational-rectification-e2e.test.ts`; the PG14 full-flow test rejects `05:20`, confirms exact `05:21`, and proves the old time survives until commit. | Authenticated production exact-candidate confirmation with old-time preservation, plus transient deployment-error probe. | -| ISSUE-BT-004 | Choosing `都不符合` surfaced the same raw English pattern error. | verified-local | The actual orchestrator treats `都不符合` as a normal direction change; Task 12 E2E advances the durable turn and preserves the single fee; client maps terminal 502/non-JSON failures to stable Chinese copy. | Authenticated production `都不符合` action followed by reload/resume, tied to the deployed SHA. | -| ISSUE-BT-005 | Initialization used generic broad-year choices and lost the rich card/chat rectification analysis. | verified-local | Task 9 onboarding soft gate; v3 narrative grounding rejects broad-year questionnaires; Task 12 asserts candidate boundary, D1/D9/D10 layers, three domain rationales, free text, and year/month event request; PG14 proves future background persists without scoring and the legacy suite imports old unfinished work once with `migration_waived`. | Authenticated synthetic first-turn snapshot and one legacy import smoke tied to the deployed SHA. | - -## Initial production diagnosis (preserved) - -The original issue record captured these causes before implementation: - -- Chat deletion lacked both an authenticated `DELETE` grant and an owner-only - RLS policy. -- `startNewChat()` created only a chat session; the birth profile and - rectification case were account-scoped, so a new chat could not start an - explicit re-rectification while preserving the active chart. -- `dynamic-choice-v2` emitted `request_candidate_confirmation`, but the client - sent the legacy `confirm_guided_candidate` mutation, which rejected dynamic - cases and left them in `confirming` without applying the candidate. -- The reported `都不符合` failure coincided with a deployment replacement in - which Caddy returned HTTP 502 while Docker could not resolve the temporarily - unavailable `web` service. -- WebKit can represent a non-JSON parse failure as a `DOMException` named - `SyntaxError`; the old transport recognized JavaScript `SyntaxError` only and - could expose `The string did not match the expected pattern.` instead of - stable Chinese product copy. - -The original closure checklist required owner-only deletion, explicit -account-level re-rectification, atomic idempotent candidate confirmation, -deployment availability or bounded retry behavior, stable localized WebKit -errors, and authenticated end-to-end coverage for `都不符合`, confirmation, -re-rectification, and deletion. The table above maps each requirement to the -implemented local evidence and the remaining production proof. - -## Release decision - -All five issues remain `verified-local` until the production closure artifacts -above are attached. A public 200 response, an unverified browser session, or a -local in-memory test cannot change them to `closed`. The deployment sequence and -non-destructive rollback are defined in `deploy/README.md`. The executable -creation policy keeps rollout `smoke_only` for one unlogged synthetic account -until the smoke SHA matches the exact deployed revision; ordinary users cannot -incur a new rectification charge during that canary window. diff --git a/docs/research/web_vs_local_birth_time_rectification_diagnosis_2026_08_01.md b/docs/research/web_vs_local_birth_time_rectification_diagnosis_2026_08_01.md deleted file mode 100644 index 8fa6eab9..00000000 --- a/docs/research/web_vs_local_birth_time_rectification_diagnosis_2026_08_01.md +++ /dev/null @@ -1,205 +0,0 @@ -# Web 生时纠正 vs 本地 Claude Code:差异诊断与改进方案 - -> 日期:2026-08-01 -> 状态:诊断完成;改进方案第 4 节"完整 MVP"已实施(见第 7 节) -> 范围:`jyotish-vedic-astrology` skill 方法论层 vs Web `skills/birth-time-rectification` 受限产品层 - -## 结论摘要 - -Web(staging)上用户感受到的"生时纠正交互僵硬、问题像硬编码模板",**不是 bug,而是两种刻意不同的架构**: - -- **本地 Claude Code**:LLM 是**主分析师**,走 `jyotish-vedic-astrology` skill 的完整方法论,可自由多轮提问、运行脚本、交叉验证,最终产出精确出生分钟。 -- **Web(Mastra)**:LLM 是**被约束的叙述者**,走 `skills/birth-time-rectification/SKILL.md`(36 行受限证据工作流)。服务端 Python 引擎 + TS 状态机拥有全部计算(候选扫描/评分/诊断/事件 ID/策略门控),LLM 只能在服务端预建的问题机会里选一个、再渲染短中文回复,永不确认单一分钟。 - -staging 前端实际挂载的是 **v4 rectification** 入口(`RectificationV4Panel` → `/api/rectification/v4/*`)。"硬编码感"主要来自服务端模板问题生成器 `opportunity-builder.ts`,与 v4/v5 模式切换无关。 - ---- - -## 1. 两个系统的架构对比 - -| 维度 | 本地 Claude Code | Web(Mastra v4 rectification) | -|---|---|---| -| 使用的 skill | `jyotish-vedic-astrology`(仓库根 `SKILL.md`,712 行,版本 6.9.14) | `skills/birth-time-rectification/SKILL.md`(36 行) | -| skill 目录结构 | symlink 指向根目录 `SKILL.md` + `references/`(100+ 方法论文档)+ `scripts/`(`jyotish_engine.py` 37 子命令)+ `assets/` | 36 行 SKILL.md + 6 个契约文件在 `skills/birth-time-rectification/references/` + `assets/rectification-capability-matrix.json` | -| 方法论 | 8 大方法(Dasha+Transit、D9 Navamsa、D10 Dasamsa、六亲、外表体质、身体缺陷、职业判断、卜卦【AI 暂不支持】);五阶段流程(收集→±30min→±15min→事件验证→D9/D10 收口到 ±5min→报告);决策树权重 Dasha 40% / D9+D10 35% / 专题层 15% / Nakshatra Pada 10% | 受限证据工作流:服务端扫描候选时间簇→评分→生成高信息量机会;agent 每轮只问一个自然问题;输出候选区间而非确定时间 | -| LLM 角色 | 主分析师,自由推理 | 被约束的叙述者(reasoner 选机会 / renderer 渲染) | -| 计算归属 | LLM 驱动 + `scripts/` 脚本 + 外部 oracle(PyJHora/VedAstro/jyotishganit) | 服务端 Python 引擎 + TS 状态机全拥有 | -| 输出 | 验证后的出生分钟(±5min) | 候选区间(`profiles.active_birth_time` 永不直接写入) | -| 错误处理 | 交互式纠错 | 幂等重放(action receipt + fingerprint)、确定性回退 | - -**两者的关系**:`jyotish-vedic-astrology` skill 内部同时定义了这两层——方法论层(`references/birth-time-rectification-advanced.md`)和受限产品工作流层(独立的 `skills/birth-time-rectification/`)。Web 端刻意只暴露受限产品层。 - ---- - -## 2. 为什么 Web 无法复刻本地交互(6 个根源) - -### 2.1 权威模型相反(设计边界,不是 bug) - -`skills/birth-time-rectification/SKILL.md` 硬边界原文: - -> - The server owns candidate scanning, scores, diagnostics, event IDs, and policy gates. -> - The agent may select one server-provided opportunity or request one server-provided diagnostic. -> - Never invent candidate times, scores, event IDs, dates, techniques, or tool inputs. -> - Never confirm a single minute or write `profiles.active_birth_time`. - -这是一整套产品决策:**计费**(`billing.ts` reserve/complete/release)、**不暴露内部分数**(用户只能看到"候选区间"而非权重/评分)、**可靠性**(服务端计算确定性可审计,LLM 只做叙述)、**truth-overlay 合规**(`references/oracle/rectification_technique_usage_audit_2026_07_19.json` 把 D9/D10/D60 等标为"敏感度证据不是证明")。本地 Claude Code 没有这些约束,所以能做完整方法论。 - -### 2.2 问题是服务端模板("硬编码感"最强处) - -`frontend/src/lib/rectification-agent/opportunity-builder.ts`: - -- **固定模板文案**:`prompt` 字段全部是写死的句子,例如—— - - `clarify_event_subject`:"你刚才提到"X",这件事主要发生在你本人,还是家人或伴侣身上?" - - `refine_event_date`:"关于"X",你还记得更具体的月份或日期吗?不确定也可以只说大概范围。" - - `ask_new_event` 各领域:career/relationship/health_pressure 等各一句。 -- **硬编码 utility 公式**(L24-30):`.35*expectedInformationGain + .20*dateSensitivity + .15*candidateSplitRelevance + .10*domainCoverageGain + .10*recallEase + .10*novelty + routingValue[kind] - repetitionPenalty - privacyCost`,其中 `routingValue` 也是写死的(L14-22)。 -- reasoner(`reasoner-agent.ts`)只按 `opportunityId` 选一个机会,**从不用自己的话提问**。 - -> v3 对话式(`/api/birth-time-conversation`)的 `narrative-agent.ts` 已带 `freeConversation` 设置、允许 agent 自由措辞——但 v3 后端未接入当前 UI 面板。 - -### 2.3 每轮只问一个问题 - -skill turn strategy:"Ask one natural question only"。`reasoner-agent.ts` 的决策被 `rectificationDecisionSchema` 严格约束,`maxToolCalls` 默认 1、最多一次 `run_rectification_diagnostics` 工具调用,然后必须返回终态动作。本地 Claude Code 是自由多轮对话。 - -### 2.4 Mastra skill 懒加载 - -`@mastra/core`(v1.50.1)的 skill 机制:`skills: [skillPath]` 只把 skill **元数据**(name/description,`` 块)注入系统提示;完整 `SKILL.md` 要模型主动调 `skill` 工具才在对话中加载(`node_modules/@mastra/core/dist/` 的 `SkillsProcessor`)。deepseek 走 `structuredOutput` 路径时未必稳定触发 `skill` 工具 → LLM 实际可用的指令比预期少。 - -### 2.5 硬编码业务规则 - -- `references/rectification_policy.v1.json`:`minScoringEvents=1`、`minConfirmationEvents=4`、`minConfirmationDomains=3`、`maxExternalValidationWidthMinutes=15`、`maxConfirmationWidthMinutes=5`、`minConfirmationMarginPercent=20`、`maxPlateauRounds=2`。→ 必须凑够 ≥4 个事件、≥3 个领域,否则一直追问,造成"问卷感"。 -- 时段区间(`orchestrator.ts` L565-594、`handler.ts` L426-451):early_morning/morning/afternoon/evening/late_night;不确定性(医院 ±2min、家庭 5/10/15、约估 15/30/60)。 -- 正则模式(`orchestrator.ts` L132-139):方向切换词/不确定词/肯定否定词/相对日期词。 -- 领域分类关键词表(`evidence-extractor.ts` L101-146)。 -- 回退文案(`narrative-agent.ts` L639-651)。 -- 模型 ID(`handler.ts` L831-832):`deepseek-v4-pro` / `deepseek-v4-flash`。 - -### 2.6 渲染约束 - -- reasoner/renderer 都强制 `structuredOutput` JSON(`reasoner-agent.ts` L124、`renderer-agent.ts` L62)。 -- renderer 还要 `enforceServerQuestion`(L38-40、L63)把服务端预建的 `exactQuestion` 强制覆盖进输出——LLM 措辞被服务端文案顶替。 -- 模型为 deepseek 系列(非 Claude),对话自然度与指令遵循不同。 - ---- - -## 3. staging 入口确认 - -| 项 | 结论 | 证据 | -|---|---|---| -| 前端 UI | **v4 rectification**:`ConversationalBirthTimeRectification` 只是 `RectificationV4Panel` 的别名 | `components/conversational-birth-time-rectification.tsx:20` | -| API 入口 | `/api/rectification/v4/*` | `lib/rectification-v4/client.ts`(cases / active / answer / revise / accept-range / pause/resume/abandon) | -| v4 流程内部 | `runBoundedReasoner`(reasoner-agent.ts)+ `renderPublicTurn`(renderer-agent.ts),两者 `skills: [rectificationSkillPath]`(受限 36 行 skill) | `reasoner-agent.ts:115`、`renderer-agent.ts:16` | -| 模型 | `RECTIFICATION_ORCHESTRATION_MODEL_ID` / `RECTIFICATION_NARRATION_MODEL_ID`(未设则默认目录) | `case-service.ts:46-47` | -| v5 agent vs v4 legacy | 由部署宿主 `.env.staging` 的 `RECTIFICATION_AGENT_V5_ENABLED` / `RECTIFICATION_AGENT_V5_CANARY_PERCENT` / `RECTIFICATION_AGENT_V5_SHADOW` 决定(`feature-policy.ts`),仓库不可见;**两种模式都走同一套受限 skill + 模板问题** | `lib/rectification-agent/feature-policy.ts:26-39` | -| v3 对话式 | 按 rollout audience(paused/smoke_only/public)门控,**未接入当前 UI 面板** | `deploy/configure-staging-rectification-rollout.sh`、`components/rectification-v4-panel.tsx`(无 v3 引用) | -| 部署副本 | Dockerfile 把 `SKILL.md`/`assets`/`references`/`scripts`/`skills` 拷进 `/app/`,symlink 保留 | `deploy/railway-web.Dockerfile:18-22` | - -> 注:`/api/health` 只上报 v3 的 rollout 状态(`rollout.conversationalRectificationV3.creationAudience`),不包含 v5 agent 的开关值,因此 v5 模式是否在 staging 开启需查部署宿主的 `.env.staging`。 - ---- - -## 4. 改进方案(在"服务端拥有计算"护栏内) - -按侵入性从低到高排列,均为**建议**(本次未实施)。任何方案都不得把内部分数/权重/事件 ID 暴露给用户,不得确认单一分钟。 - -### 4.1 即时注入 skill 指令(低侵入,收益高) - -- **改动**:把 36 行 `skills/birth-time-rectification/SKILL.md` 直接内联进 reasoner/renderer 的 `instructions`(`reasoner-agent.ts:117`、`renderer-agent.ts:17`),保留 `skills: [skillPath]` 作为能力来源。 -- **效果**:消除 Mastra skill 懒加载不确定性——模型每轮都确定拥有"turn strategy + public language + 硬边界"指令。 -- **风险**:低。指令与 skill 内容一致,只是从懒加载改为常驻。 - -### 4.2 LLM 起草问题 + 服务端 grounded 校验(中侵入,消除"模板感"核心) - -- **改动**:`opportunity-builder.ts` 保留"选哪个机会"的服务端决策(kind/targetEventId/domain/utility),但把 `prompt` 从"必须原样使用"改为"话题约束";reasoner 用自然语言起草问题文本;新增一个 grounding 校验(复用 `narrative-agent.ts` 的 grounding 思路)确认草稿:① 命中目标事件/领域 ② 不含内部分数/权重/事件 ID ③ 是单问。 -- **效果**:问题随上下文自适应,消灭"你刚才提到X…"的模板感。 -- **风险**:中。需要新增校验层与测试;reasoner 输出 schema 从"选 opportunityId"扩展为"选 opportunityId + 起草文本"。 - -### 4.3 自由对话回合(中侵入) - -- **改动**:服务端没有待处理机会(`opportunities` 为空或全部低效用)时,允许 agent 走"自然回应"而非强制提问。可复用 v3 `narrative-agent.ts` 的 `freeConversation` / `questionsAreOptional` 提示词模式,让 renderer 生成 1-3 句自然中文 + 可选开放收尾。 -- **效果**:不再每轮都是"选择题",更像本地对话。 -- **风险**:中。需防止发散、防止确认未验证分钟;收敛判定仍由服务端掌控。 - -### 4.4 渲染放宽(中侵入) - -- **改动**:renderer 从 `structuredOutput` JSON 改为自然中文文本输出 + 事后校验(`enforceServerQuestion` 保留为兜底,仅当需要明确问题时强制服务端文案)。 -- **效果**:回复更自然,减少 JSON 式僵硬措辞。 -- **风险**:中。需新的文本校验(主题、长度、泄密扫描)。 - -### 4.5 模型目录加入 Claude(低侵入,可选) - -- **改动**:`frontend/src/mastra/model.ts` 的模型目录加入 Claude(如 `claude-sonnet-5`),`RECTIFICATION_NARRATION_MODEL_ID` 指向它。 -- **效果**:叙事/对话质量显著提升(deepseek 在结构化约束下更易模板化)。 -- **风险**:低,纯配置;需确认供应商密钥与成本。 - ---- - -## 5. 不应改动(设计边界) - -以下为 `birth-time-rectification` skill 与产品契约的硬性约束,**任何改进都不得触碰**: - -1. 服务端拥有候选扫描、评分、诊断、事件 ID、策略门控。 -2. 永不确认单一分钟;永不直接写 `profiles.active_birth_time`。 -3. 候选区间只有确定性稳定门通过才对用户可见(`canAcceptRange`)。 -4. 计费幂等(billing reserve/complete/release + action receipt 指纹重放)。 -5. truth-overlay 强制降级:`reference_only`/`blocked`/`partial` 技法不得作为确定性结论(`references/oracle/skill_truth_overlay_2026_07_19.json`)。 -6. 不暴露内部分数、权重、领域标签、工具载荷、agent 轨迹。 - -改进目标是让**叙述/提问的自然度**贴近本地,而不是让 Web 复刻本地的方法论深度——那需要把整条计算链路搬进 LLM 上下文,与现有产品架构冲突。 - ---- - -## 6. 附:关键文件索引 - -| 文件 | 作用 | -|---|---| -| `skills/birth-time-rectification/SKILL.md` | Web 端受限 skill(36 行硬边界) | -| `SKILL.md`(仓库根) | 本地完整 skill(712 行方法论,symlink 到 skill 目录) | -| `frontend/src/lib/rectification-agent/opportunity-builder.ts` | 服务端模板问题生成器(硬编码根源) | -| `frontend/src/lib/rectification-agent/reasoner-agent.ts` | v4/v5 reasoner(选机会 + diagnostic 工具) | -| `frontend/src/lib/rectification-agent/renderer-agent.ts` | v4/v5 renderer(渲染公开回合 + enforceServerQuestion) | -| `frontend/src/lib/rectification-agent/feature-policy.ts` | v4_legacy / v5_shadow / v5_agent 选择 | -| `frontend/src/lib/rectification-v4/case-service.ts` | 建 case、deployment_mode、模型 ID | -| `frontend/src/lib/rectification-v4/supabase-store.ts` | 持久化 deployment_mode/agent_mode | -| `frontend/src/lib/conversational-rectification/narrative-agent.ts` | v3 叙事 agent(freeConversation 参考实现) | -| `frontend/src/app/api/birth-time-conversation/handler.ts` | v3 handler(deepseek 模型 ID、流式) | -| `references/rectification_policy.v1.json` | 收敛门槛硬编码 | -| `deploy/configure-staging-rectification-rollout.sh` | staging rollout(paused/smoke_only/public) | -| `frontend/supabase/migrations/20260728020000_*.sql` | v5 列(deployment_mode/agent_mode/model id/version) | - ---- - -## 7. 已实施:Agentic 生时纠正 MVP(2026-08-01) - -按用户决策"完全复刻本地方法论",实现了一个新的 **agentic 生时纠正**聊天流:LLM 挂载完整 `jyotish-vedic-astrology` skill,像本地 Claude Code 一样驱动方法论,通过引擎工具请求计算(而不是自己瞎算),自由多轮对话,最终经高 rigor 确认门 + 用户明确同意后写回 `profiles.active_birth_time`。 - -### 7.1 新增文件 - -| 文件 | 作用 | -|---|---| -| `frontend/src/mastra/rectification-tools.ts` | 7 个工具包 Python 引擎端点:`rectification-gate`(精度门)、`rectification-scan`(分钟敏感度扫描)、`rectification-score`(V5 矩阵评分)、`rectification-diagnostics`(鲁棒性诊断)、`rectification-candidate-features`(候选静态特征)、`rectification-confirm`(高 rigor 三引擎 parity 确认门)、`rectification-save-birth-time`(服务端双重校验后写 profile) | -| `frontend/src/mastra/agentic-rectification.ts` | agent 工厂:完整 skill + 工具 + 中文指令(方法论流程、truth overlay、保存门控) | -| `frontend/src/lib/rectification-agentic/session.ts` | 会话支持:加载 profile 出生字段、`applyConfirmedBirthTime` 调 service-role RPC 写回 | -| `frontend/src/app/api/rectification/agent/route.ts` | NDJSON 流式端点:认证 → profile → 计费 reserve → agent.stream → delta/done 事件 → settle | -| `frontend/src/components/rectification-agentic-chat.tsx` | 聊天面板:流式渲染、隐藏块解析(suggestions/title/保存哨兵)、错误处理 | -| `frontend/src/components/conversational-birth-time-rectification.tsx` | 入口智能切换:有进行中的 v4 case → v4 面板恢复;否则 → agentic 聊天 | -| `frontend/supabase/migrations/20260801000000_agentic_rectification_profile_write.sql` | `apply_agentic_rectification_birth_time` RPC(security definer,仅 service_role,含基线并发保护) | -| `frontend/tests/rectification-agentic-tools.test.ts` / `rectification-agentic-session.test.ts` | 12 个测试 | - -### 7.2 安全门控(核心) - -LLM 绝不能写任意分钟。`rectification-confirm` 只有在引擎高 rigor 门全过(≥4 事件、≥3 领域、宽度/边际阈值、三引擎 parity、外部 VedAstro 校验)返回 `confirmation_allowed=true` + 确认分钟时,才在会话闭包中设置 `confirmedGate`;`rectification-save-birth-time` 要求请求的时间**恰好等于**该确认分钟,才调用 RPC 写库。RPC 还带 `p_baseline_time` 并发保护(当前 active 时间必须仍是会话开始时的基线)。 - -### 7.3 验证 - -- `npx tsx --test tests/*.test.ts`:**1076 全通过**(含 12 个新测试)。 -- `npx tsc --noEmit`:新文件零错误(仓库剩余 5 个为预先存在)。 -- `npx eslint`:新文件零错误零警告。 - -### 7.4 待办/注意 - -- **引擎端点鉴权**:`rectification-save-birth-time` 走的 RPC 仅 service_role;引擎各 rectification 端点无需 token(与 `runConsultationWorkflow` 一致)。 -- **计费**:按消息 reserve/complete/cancel 咨询点数(复用 `begin/complete/cancel_consultation_credit`)。 -- **v4 保留**:有进行中 v4 case 时仍走 v4 面板恢复,不丢数据。 -- **模型**:默认走当前模型目录;若想让叙事用 Claude,在 `LLM_MODELS_JSON` 加 Claude 项并把 `LLM_DEFAULT_MODEL_ID` 指过去即可。 -- **部署**:新路由无需新环境变量(复用 `JYOTISH_API_BASE`、Supabase 密钥、模型目录);新迁移需在 staging 执行 `db:migrate`。 diff --git a/docs/superpowers/plans/2026-07-17-birth-time-journey.md b/docs/superpowers/plans/2026-07-17-birth-time-journey.md deleted file mode 100644 index 97ecdb05..00000000 --- a/docs/superpowers/plans/2026-07-17-birth-time-journey.md +++ /dev/null @@ -1,115 +0,0 @@ -# Birth Time Journey 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:** Build a deterministic first-use birth-time journey that separates reported and active times, routes uncertain data into free rectification, and connects the web UI to the existing candidate scanner. - -**Architecture:** A pure TypeScript state machine owns route and application decisions. An authenticated Next.js route adapts Supabase persistence and the existing Python scan/score API to that state machine. A focused React component renders the input contract, while `page.tsx` only coordinates the established onboarding shell. - -**Tech Stack:** Next.js 16 App Router, React 19, TypeScript, Zod, Supabase/PostgreSQL, Node test runner, Python Jyotish API. - -## Global Constraints - -- Agent copy may guide the user but may not determine route, confidence, or application eligibility. -- `reported_birth_time` is immutable historical input; `birth_time` mirrors only `active_birth_time` for compatibility. -- Rectification intake and questions never call the consultation billing endpoint. -- Questionnaire scoring cannot apply an exact minute because the current engine only ranks coarse clusters. -- Scanner failure must fail closed into rectification. -- Do not modify or import files from `.workbuddy` mirrors. - ---- - -### Task 1: Deterministic Journey Domain - -**Files:** -- Create: `frontend/src/lib/birth-time-journey.ts` -- Test: `frontend/tests/birth-time-journey.test.ts` - -**Interfaces:** -- Produces: `assessBirthTime(input: BirthTimeAssessmentInput, scan?: CandidateScan): JourneySnapshot` -- Produces: `scoreJourneyAnswers(snapshot: JourneySnapshot, scoring: RectificationScoring): JourneySnapshot` -- Produces: source, period, status, route, input, snapshot, scan, and scoring types used by later tasks. - -- [ ] Write table-driven failing tests for all five sources, invalid source-specific input, stable hospital scan, sensitive hospital scan, scanner failure, and `canApply=false` after questionnaire scoring. -- [ ] Run `npm test -- --test-name-pattern='birth time journey'` and confirm the module is missing. -- [ ] Implement exhaustive source routing and scan stability comparison without persistence or prose generation. -- [ ] Run the focused test and confirm every route and gate passes. - -### Task 2: Birth-Time Persistence Contract - -**Files:** -- Create: `frontend/supabase/migrations/20260717020000_birth_time_journey.sql` -- Create: `tests/test_birth_time_journey_contract.py` - -**Interfaces:** -- Produces: profile columns and `public.birth_time_rectification_cases` expected by the route. - -- [ ] Write a failing SQL contract test for columns, checks, backfill, foreign key, RLS policies, and column-level grants. -- [ ] Run `/Users/jesse/Downloads/Copse/astrology/yinduzhanxing/.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py` and confirm the migration is missing. -- [ ] Add an idempotent migration that backfills old `birth_time` values, constrains enums and uncertainty ranges, creates the cases table, and grants only owner-scoped operations. -- [ ] Run the SQL contract test and the existing Supabase contract tests. - -### Task 3: Authenticated Journey Service and Route - -**Files:** -- Create: `frontend/src/lib/birth-time-journey-service.ts` -- Create: `frontend/src/app/api/birth-time-journey/route.ts` -- Test: `frontend/tests/birth-time-journey-service.test.ts` - -**Interfaces:** -- Consumes: domain types and `assessBirthTime`/`scoreJourneyAnswers` from Task 1. -- Produces: `POST /api/birth-time-journey` events `assess` and `answer_question`. - -- [ ] Write failing service tests with fake persistence and scanner ports for stable assessment, scanner failure, and answer accumulation. -- [ ] Implement a typed service port so tests never require live Supabase or Python. -- [ ] Implement the route's Zod boundary, authenticated profile read, free scanner calls, case persistence, and sanitized JSON response. -- [ ] Run focused service/domain tests and lint. - -### Task 4: First-Use Birth Intake UI - -**Files:** -- Create: `frontend/src/components/birth-time-intake.tsx` -- Create: `frontend/src/components/birth-time-rectification.tsx` -- Modify: `frontend/src/app/page.tsx` -- Modify: `frontend/src/app/globals.css` -- Test: `frontend/tests/birth-time-intake.test.ts` - -**Interfaces:** -- Consumes: the journey source/status types and `JourneySnapshot`. -- Produces: source-specific profile draft updates, assessment requests after location, and answer events. - -- [ ] Write failing tests for source-specific required fields, summary labels, and payload construction. -- [ ] Implement the source cards, conditional fields, accessible labels, and uncertainty/period copy. -- [ ] Implement the rectification status/question card with progress and explicit non-application language. -- [ ] Replace the old exact-time-only fields in `page.tsx`, extend profile parsing/persistence, add the `rectification` onboarding step, and block consultation until an active time exists. -- [ ] Add scoped responsive styles and run the focused UI helper tests plus lint. - -### Task 5: Compatibility and End-to-End Verification - -**Files:** -- Modify: `frontend/src/app/api/onboarding/route.ts` -- Modify: `frontend/src/mastra/index.ts` -- Modify: `tests/test_frontend_productization.py` - -**Interfaces:** -- Consumes: active time and birth-time status persisted by earlier tasks. -- Produces: existing onboarding and consultation behavior with deterministic entry mode. - -- [ ] Update onboarding completeness to require an active/confirmed time while accepting backfilled legacy profiles. -- [ ] Add `entryMode` to the consultation input and pass the deterministic value to the Python workflow instead of hard-coding `direct_chart`. -- [ ] Add regression assertions that the web path exposes five time-confidence choices, keeps rectification free, and contains no client-controlled application gate. -- [ ] Run frontend tests, relevant Python tests, lint, and `npm run build`. -- [ ] Start Next.js from the worktree and manually verify the first-use UI, source-dependent fields, rectification card, `/api/birth-time-journey` authentication behavior, and absence of consultation credit requests. - -### Task 6: Review and Commit - -**Files:** -- Review every path changed by Tasks 1-5. - -**Interfaces:** -- Produces: a review-clean commit on `codex/birth-time-journey`. - -- [ ] Run the TypeScript no-excuse checks and measure pure LOC for every changed source file. -- [ ] Review boundary parsing, exhaustive variants, RLS, billing isolation, and legacy compatibility. -- [ ] Re-run the full frontend test/lint/build gate and relevant Python contract tests on the final diff. -- [ ] Commit the implementation with a focused message and record the worktree path and commit SHA. diff --git a/docs/superpowers/plans/2026-07-18-agent-guided-birth-time-rectification.md b/docs/superpowers/plans/2026-07-18-agent-guided-birth-time-rectification.md deleted file mode 100644 index 945e9d5e..00000000 --- a/docs/superpowers/plans/2026-07-18-agent-guided-birth-time-rectification.md +++ /dev/null @@ -1,707 +0,0 @@ -# Agent-Guided Birth-Time Rectification 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:** Replace the fixed questionnaire/manual comparison path with a deterministic, versioned JourneyTurn that asks one high-information question at a time, lets an Agent create review-only evidence drafts, and automatically advances to the next question or guarded result. - -**Architecture:** Extend the existing `BirthTimeJourney` as the only state authority. A pure planner ranks canonical evidence domains from actual candidate Varga differences; a constrained Mastra Agent may phrase a server-selected question and extract a draft, but only authenticated structured UI actions can confirm evidence, run scoring, save a candidate, or confirm an active time. Persist `nextAction`, progress, optimistic version, idempotency receipts, drafts, and scoring jobs so refresh/resume cannot produce a dead end. - -**Tech Stack:** TypeScript 5, Zod 3, Next.js 16.2 Route Handlers, React 19 Client Components, Mastra 1.50, Supabase/PostgreSQL, Python 3.11+, Node test runner, pytest. - -## Global Constraints - -- Preserve all existing dirty work; inspect the diff before every edit and never reset, restore, or overwrite unrelated changes. -- Agent prose never determines candidate ranking, confidence, route, progress, or permission. -- Every scored event is structured and explicitly confirmed by the user. -- Baseline scoring requires at least three confirmed events across two domains. -- Low-confidence adaptive questioning is capped at three displayed questions; skip consumes the displayed round. -- Medium confidence saves only; high confidence still requires explicit confirmation of the matching result ID and representative time. -- `reported_birth_time` remains immutable; only guarded confirmation may update `active_birth_time`. -- Keep the legacy response `canApply` compatibility parser, but new UI and Agent permissions use `canConfirmCandidate`. -- Use “候选时间” and “当前排盘使用时间”; never claim a proven true birth minute. -- Read `frontend/node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md` and `05-server-and-client-components.md` before editing Next.js code. -- No new runtime dependency is permitted. -- Each implementation task follows red → green TDD and stages only files owned by that task. - ---- - -### Task 1: Deterministic Candidate-Difference Question Planner - -**Files:** -- Modify: `scripts/active_rectification_questions.py` -- Modify: `frontend/src/lib/birth-time-journey-adapters.ts` -- Create: `frontend/src/lib/birth-time-question-planner.ts` -- Modify: `tests/test_active_rectification_questions.py` -- Create: `frontend/tests/birth-time-question-planner.test.ts` - -**Interfaces:** -- Consumes: candidate scan samples with D4/D9/D10/D24/D30 Ascendant signs. -- Produces: `planEvidenceQuestion(input: QuestionPlannerInput): QuestionSpec | null` and `QuestionSpec` for Tasks 2, 4, and 6. - -- [ ] **Step 1: Add failing Python coverage for all canonical domain Vargas** - -```python -def test_candidate_recast_contains_all_evidence_domain_vargas(monkeypatch): - report = build_questionnaire( - "1993-04-17 14:30", 30, 30, - lat=31.2304, lon=121.4737, tz=8, - ) - sample = report["candidate_scan"]["samples"][0] - assert {"D4", "D9", "D10", "D24", "D30"}.issubset(sample["varga_lagna"]) -``` - -- [ ] **Step 2: Run the Python test and verify RED** - -Run: `.venv/bin/python -m pytest -q tests/test_active_rectification_questions.py -k evidence_domain_vargas` - -Expected: FAIL because `_candidate_recast()` currently omits division 4. - -- [ ] **Step 3: Add D4 to the recast and expose five parsed signs** - -Change `varga.calc_all_vargas(... divisions=[4, 9, 10, 24, 30, 60])`. Extend `RectificationQuestionnaire.samples` and the adapter with `d4Sign`, `d9Sign`, `d10Sign`, `d24Sign`, and `d30Sign`. - -- [ ] **Step 4: Add failing planner tests** - -```ts -test("planner chooses the unasked domain with the largest candidate split", () => { - const question = planEvidenceQuestion({ - phase: "baseline", - samples: [ - { d4Sign: "Aries", d9Sign: "Cancer", d10Sign: "Leo", d24Sign: "Gemini", d30Sign: "Virgo" }, - { d4Sign: "Taurus", d9Sign: "Cancer", d10Sign: "Leo", d24Sign: "Gemini", d30Sign: "Virgo" }, - { d4Sign: "Gemini", d9Sign: "Cancer", d10Sign: "Leo", d24Sign: "Gemini", d30Sign: "Virgo" }, - ], - askedDomains: [], - coveredDomains: [], - adaptiveRound: 0, - }); - assert.equal(question?.domain, "relocation"); - assert.equal(question?.phase, "baseline"); -}); - -test("planner never repeats a domain and returns null after canonical domains are exhausted", () => { - assert.equal(planEvidenceQuestion({ - phase: "baseline", - samples: [], - askedDomains: ["education", "relocation", "relationship", "career", "health_pressure"], - coveredDomains: [], - adaptiveRound: 0, - }), null); -}); -``` - -- [ ] **Step 5: Run the planner test and verify RED** - -Run: `cd frontend && node --test tests/birth-time-question-planner.test.ts` - -Expected: FAIL because the planner module does not exist. - -- [ ] **Step 6: Implement the pure planner** - -```ts -export const evidenceDomains = [ - "education", "relocation", "relationship", "career", "health_pressure", -] as const; - -const layerByDomain = { - education: "d24Sign", - relocation: "d4Sign", - relationship: "d9Sign", - career: "d10Sign", - health_pressure: "d30Sign", -} as const; - -export function planEvidenceQuestion(input: QuestionPlannerInput): QuestionSpec | null { - const available = evidenceDomains.filter((domain) => !input.askedDomains.includes(domain)); - const ranked = available.map((domain) => ({ - domain, - split: new Set(input.samples.map((sample) => sample[layerByDomain[domain]]).filter(Boolean)).size, - coverageBonus: input.coveredDomains.includes(domain) ? 0 : 1, - })).sort((left, right) => right.split - left.split - || right.coverageBonus - left.coverageBonus - || evidenceDomains.indexOf(left.domain) - evidenceDomains.indexOf(right.domain)); - const winner = ranked[0]; - return winner ? questionSpecFor(winner.domain, input.phase, input.adaptiveRound) : null; -} -``` - -- [ ] **Step 7: Run focused tests and verify GREEN** - -Run: `.venv/bin/python -m pytest -q tests/test_active_rectification_questions.py && cd frontend && node --test tests/birth-time-question-planner.test.ts tests/birth-time-journey-adapters.test.ts` - -Expected: all selected tests pass. - -- [ ] **Step 8: Commit the isolated planner change** - -```bash -git add scripts/active_rectification_questions.py tests/test_active_rectification_questions.py frontend/src/lib/birth-time-question-planner.ts frontend/src/lib/birth-time-journey-adapters.ts frontend/tests/birth-time-question-planner.test.ts frontend/tests/birth-time-journey-adapters.test.ts -git commit -m "feat: plan adaptive birth time evidence questions" -``` - ---- - -### Task 2: JourneyTurn, NextAction, Progress, and Permission Protocol - -**Files:** -- Create: `frontend/src/lib/birth-time-journey-turn.ts` -- Modify: `frontend/src/lib/birth-time-journey-service.ts` -- Modify: `frontend/src/lib/birth-time-journey-client.ts` -- Create: `frontend/tests/birth-time-journey-turn.test.ts` -- Modify: `frontend/tests/birth-time-journey-client.test.ts` - -**Interfaces:** -- Consumes: `QuestionSpec`, `CandidateResult`, and confirmed `LifeEvent[]`. -- Produces: `NextAction`, `JourneyProgress`, `JourneyPermissions`, `JourneyTurnState`, `deriveNextAction()`, and parsed response fields for later tasks. - -- [ ] **Step 1: Write failing invariants tests** - -```ts -test("a fresh rectification turn asks exactly one baseline evidence question", () => { - const turn = createInitialJourneyTurn(question("career")); - assert.equal(turn.nextAction.kind, "ask_baseline_evidence"); - assert.equal(turn.progress.confirmedEvidenceCount, 0); - assert.equal(turn.progress.maxAdaptiveRounds, 3); - assert.equal(turn.permissions.canConfirmCandidate, false); -}); - -test("the third low adaptive result becomes terminal", () => { - const next = deriveNextAction({ - progress: { phase: "adaptive", baselineDomainCount: 3, confirmedEvidenceCount: 6, adaptiveRound: 3, maxAdaptiveRounds: 3 }, - candidateResult: lowResult, - nextQuestion: question("health_pressure"), - }); - assert.equal(next.kind, "present_low_result"); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test tests/birth-time-journey-turn.test.ts` - -Expected: FAIL because the protocol module does not exist. - -- [ ] **Step 3: Implement strict Zod schemas and pure transitions** - -```ts -export const nextActionSchema = z.discriminatedUnion("kind", [ - z.object({ kind: z.literal("ask_baseline_evidence"), question: questionSpecSchema }), - z.object({ kind: z.literal("ask_adaptive_evidence"), question: questionSpecSchema }), - z.object({ kind: z.literal("review_evidence_draft"), draftId: z.string().uuid() }), - z.object({ kind: z.literal("score_pending"), jobId: z.string().uuid() }), - z.object({ kind: z.literal("retry_scoring"), jobId: z.string().uuid() }), - z.object({ kind: z.literal("present_low_result"), resultId: z.string().uuid().nullable() }), - z.object({ kind: z.literal("present_medium_result"), resultId: z.string().uuid() }), - z.object({ kind: z.literal("request_candidate_confirmation"), resultId: z.string().uuid() }), - z.object({ kind: z.literal("ready"), activeTime: z.string() }), - z.object({ kind: z.literal("paused") }), -]); -``` - -`deriveNextAction()` must exhaustively map: baseline incomplete → one baseline question; low and adaptive round < 3 → one adaptive question; low at round 3 → terminal low; medium → terminal medium; high → confirmation; confirmed → ready. - -- [ ] **Step 4: Extend service and client response types** - -Add `nextAction`, `progress`, `permissions`, `turnVersion`, and nullable `evidenceDraft` to `JourneyResponse` and its client Zod schema. Keep defaults only in the legacy-normalization path; new responses must provide all fields. - -- [ ] **Step 5: Add parser rejection coverage** - -```ts -test("client rejects a nonterminal turn without nextAction", () => { - assert.throws(() => parseJourneyResponse({ ...validTurn, nextAction: undefined })); -}); - -test("client does not expose legacy canApply as Agent permission", () => { - const parsed = parseJourneyResponse(highConfirmationTurn); - assert.equal(parsed.permissions.canConfirmCandidate, true); - assert.equal("canApply" in parsed.permissions, false); -}); -``` - -- [ ] **Step 6: Run focused tests and verify GREEN** - -Run: `cd frontend && node --test tests/birth-time-journey-turn.test.ts tests/birth-time-journey-client.test.ts` - -Expected: all selected tests pass. - -- [ ] **Step 7: Commit the protocol** - -```bash -git add frontend/src/lib/birth-time-journey-turn.ts frontend/src/lib/birth-time-journey-service.ts frontend/src/lib/birth-time-journey-client.ts frontend/tests/birth-time-journey-turn.test.ts frontend/tests/birth-time-journey-client.test.ts -git commit -m "feat: define versioned birth time journey turns" -``` - ---- - -### Task 3: Persisted Turn Version, Drafts, and Idempotency Receipts - -**Files:** -- Create: `frontend/supabase/migrations/20260718020000_agent_guided_birth_time_rectification.sql` -- Modify: `frontend/src/lib/birth-time-journey-store.ts` -- Modify: `frontend/src/lib/birth-time-journey-service.ts` -- Modify: `tests/test_birth_time_journey_contract.py` -- Modify: `frontend/tests/birth-time-journey-service.test.ts` - -**Interfaces:** -- Produces: `saveTurn(value, expectedVersion, actionId)`, `StaleJourneyTurnError`, stored `turnVersion`, `turnState`, `evidenceDraft`, `processedActionIds`. -- Consumed by Tasks 4 and 5. - -- [ ] **Step 1: Add failing migration contract assertions** - -```python -def test_agent_guided_rectification_migration_versions_turns_and_jobs(): - sql = MIGRATION.read_text() - assert "turn_version bigint not null default 0" in sql - assert "turn_state jsonb not null default" in sql - assert "evidence_draft jsonb" in sql - assert "processed_action_ids uuid[]" in sql - assert "birth_time_rectification_scoring_jobs" in sql -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py -k agent_guided` - -Expected: FAIL because the migration does not exist. - -- [ ] **Step 3: Create the additive migration** - -The migration must add typed JSON checks, a bounded `processed_action_ids` array, a service-role-only scoring job table with random UUID primary key, ownership, status, expiry, and unique `(case_id, evidence_fingerprint, algorithm_version)`. Do not grant job-table access to `authenticated` or `anon`. - -- [ ] **Step 4: Add a failing optimistic-concurrency service test** - -```ts -test("stale turn versions cannot overwrite the current action", async () => { - await assert.rejects( - service.skipEvidenceQuestion("user-1", "case-1", actionId, 4), - StaleJourneyTurnError, - ); - assert.equal(memory.savedCase()?.turnVersion, 5); -}); -``` - -- [ ] **Step 5: Implement atomic store writes** - -Use one Supabase update constrained by `.eq("turn_version", expectedVersion)` and owner ID. Append the action ID and increment the version in the same statement. If no row is returned, reload: return the current case when `processedActionIds` already includes the action ID; otherwise throw `StaleJourneyTurnError`. - -- [ ] **Step 6: Run focused tests and verify GREEN** - -Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py && cd frontend && node --test tests/birth-time-journey-service.test.ts` - -Expected: migration and concurrency tests pass. - -- [ ] **Step 7: Commit persistence** - -```bash -git add frontend/supabase/migrations/20260718020000_agent_guided_birth_time_rectification.sql frontend/src/lib/birth-time-journey-store.ts frontend/src/lib/birth-time-journey-service.ts tests/test_birth_time_journey_contract.py frontend/tests/birth-time-journey-service.test.ts -git commit -m "feat: persist versioned rectification turns" -``` - ---- - -### Task 4: Draft Confirmation, Skip, Pause, Resume, and Automatic Service Progression - -**Files:** -- Modify: `frontend/src/lib/birth-time-evidence.ts` -- Modify: `frontend/src/lib/birth-time-evidence-service.ts` -- Modify: `frontend/src/lib/birth-time-journey-service.ts` -- Modify: `frontend/src/app/api/birth-time-journey/route.ts` -- Modify: `frontend/src/lib/birth-time-journey-client.ts` -- Modify: `frontend/tests/birth-time-evidence.test.ts` -- Modify: `frontend/tests/birth-time-journey-service.test.ts` -- Modify: `frontend/tests/birth-time-journey-client.test.ts` - -**Interfaces:** -- Produces: `proposeEvidenceDraft`, `confirmEvidenceDraft`, `skipEvidenceQuestion`, `pause`, `finishWithCurrentRange`, and legacy `resume` normalization. -- Calls `planEvidenceQuestion()` and Task 3 store writes. - -- [ ] **Step 1: Add failing end-to-end service tests with a memory store** - -```ts -test("confirmed drafts automatically advance from baseline to scoring", async () => { - const first = await service.proposeEvidenceDraft(userId, caseId, actionId1, 0, careerDraft); - assert.equal(first.nextAction.kind, "review_evidence_draft"); - const confirmed = await service.confirmEvidenceDraft(userId, caseId, actionId2, first.turnVersion, first.evidenceDraft!.id); - assert.equal(confirmed.progress.confirmedEvidenceCount, 1); - assert.equal(confirmed.nextAction.kind, "ask_baseline_evidence"); -}); - -test("a third confirmed baseline event starts scoring without a compare action", async () => { - const result = await confirmThirdDraft(); - assert.equal(result.nextAction.kind, "score_pending"); - assert.equal(engine.scoreEventsCalls, 0); -}); - -test("resume reconstructs one deterministic action for a legacy dead-end snapshot", async () => { - const result = await service.resume(userId, legacyCaseId); - assert.equal(result.nextAction.kind, "ask_baseline_evidence"); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test --test-name-pattern="draft|third confirmed|legacy dead-end" tests/birth-time-journey-service.test.ts` - -Expected: FAIL because these actions do not exist. - -- [ ] **Step 3: Add a strict evidence draft schema** - -Drafts carry `id`, server-selected `questionId`/`domain`, nullable precision/date, `status: "draft"`, and `needsReview`. `confirmEvidenceDraft` must parse the final draft through `lifeEventSchema`; incomplete or domain-mismatched drafts fail closed. - -- [ ] **Step 4: Implement automatic transition rules** - -On confirmation: append the event; if baseline minimum is not met, persist the next baseline question; if met, create `score_pending`; after a low completed score, persist the next adaptive question and increment the displayed round exactly once; at round 3 persist terminal low. Skip marks the domain/question asked, consumes adaptive round only in the adaptive phase, and plans the next question. Pause persists `paused` without changing evidence. - -- [ ] **Step 5: Normalize legacy cases on resume** - -Legacy questionnaire and `life_events` snapshots without turn state must derive one current `nextAction` from stored evidence/result. Resume may repair derived turn state but must not call the external scoring engine. - -- [ ] **Step 6: Add authenticated structured API actions** - -Add strict `confirm_evidence_draft`, `skip_evidence_question`, `pause_rectification`, and `finish_rectification` request variants. Every mutation includes `caseId`, UUID `actionId`, and non-negative `turnVersion`; confirmation additionally includes only `draftId`. The client exposes `confirmBirthTimeEvidenceDraft()`, `skipBirthTimeEvidenceQuestion()`, `pauseBirthTimeRectification()`, and `finishBirthTimeRectification()` and never submits candidate score, confidence, or permissions. - -- [ ] **Step 7: Run focused tests and verify GREEN** - -Run: `cd frontend && node --test tests/birth-time-evidence.test.ts tests/birth-time-question-planner.test.ts tests/birth-time-journey-turn.test.ts tests/birth-time-journey-service.test.ts tests/birth-time-journey-client.test.ts` - -Expected: all focused service-flow tests pass. - -- [ ] **Step 8: Commit the orchestration** - -```bash -git add frontend/src/lib/birth-time-evidence.ts frontend/src/lib/birth-time-evidence-service.ts frontend/src/lib/birth-time-journey-service.ts frontend/src/app/api/birth-time-journey/route.ts frontend/src/lib/birth-time-journey-client.ts frontend/tests/birth-time-evidence.test.ts frontend/tests/birth-time-journey-service.test.ts frontend/tests/birth-time-journey-client.test.ts -git commit -m "feat: advance rectification from confirmed evidence" -``` - ---- - -### Task 5: Idempotent Score-Pending Jobs and Polling - -**Files:** -- Modify: `frontend/src/lib/birth-time-journey-store.ts` -- Modify: `frontend/src/lib/birth-time-evidence-service.ts` -- Modify: `frontend/src/app/api/birth-time-journey/route.ts` -- Modify: `frontend/src/lib/birth-time-journey-client.ts` -- Modify: `frontend/tests/birth-time-journey-service.test.ts` -- Modify: `frontend/tests/birth-time-journey-client.test.ts` -- Modify: `tests/test_birth_time_journey_contract.py` - -**Interfaces:** -- Produces: `createScoringJob`, `pollScoringJob`, `completeScoringJob`, `failScoringJob`, API action `poll_scoring`, and client `pollBirthTimeScoring()`. - -- [ ] **Step 1: Add failing job lifecycle tests** - -```ts -test("polling a pending job scores exactly once and atomically stores the next action", async () => { - const first = await service.pollScoringJob(userId, caseId, jobId); - const second = await service.pollScoringJob(userId, caseId, jobId); - assert.equal(engine.scoreEventsCalls, 1); - assert.deepEqual(second.nextAction, first.nextAction); -}); - -test("a failed job preserves evidence and exposes retry_scoring", async () => { - engine.scoreEventsError = new Error("offline"); - const result = await service.pollScoringJob(userId, caseId, jobId); - assert.equal(result.nextAction.kind, "retry_scoring"); - assert.equal(result.lifeEvents.length, 3); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test --test-name-pattern="pending job|failed job" tests/birth-time-journey-service.test.ts` - -Expected: FAIL because job APIs do not exist. - -- [ ] **Step 3: Implement owner-scoped job claim and completion** - -Only one poll may change `pending` → `processing`. A completed job returns the stored result. A failed job may be retried with the same evidence fingerprint without duplicating evidence or consuming an adaptive round. Job expiry and ownership are checked before engine invocation. - -- [ ] **Step 4: Add strict API and client contracts** - -```ts -z.object({ - type: z.literal("poll_scoring"), - caseId: z.string().uuid(), - jobId: z.string().uuid(), -}).strict() -``` - -The route authenticates first and never accepts candidate score, confidence, result, or active time from this action. - -- [ ] **Step 5: Run focused tests and verify GREEN** - -Run: `cd frontend && node --test tests/birth-time-journey-service.test.ts tests/birth-time-journey-client.test.ts && cd .. && .venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py` - -Expected: all job and API contracts pass. - -- [ ] **Step 6: Commit the scoring job path** - -```bash -git add frontend/src/lib/birth-time-journey-store.ts frontend/src/lib/birth-time-evidence-service.ts frontend/src/app/api/birth-time-journey/route.ts frontend/src/lib/birth-time-journey-client.ts frontend/tests/birth-time-journey-service.test.ts frontend/tests/birth-time-journey-client.test.ts tests/test_birth_time_journey_contract.py -git commit -m "feat: resume idempotent birth time scoring jobs" -``` - ---- - -### Task 6: Constrained BirthTimeGuideAgent and Unbilled Guide API - -**Files:** -- Create: `frontend/src/lib/birth-time-guide-agent.ts` -- Modify: `frontend/src/mastra/index.ts` -- Create: `frontend/src/app/api/birth-time-guide/route.ts` -- Modify: `frontend/src/lib/birth-time-journey-client.ts` -- Create: `frontend/tests/birth-time-guide-agent.test.ts` -- Create: `frontend/tests/birth-time-guide-route.test.ts` - -**Interfaces:** -- Produces: `getBirthTimeGuideAgent(model)`, `parseEvidenceDraftOutput()`, deterministic `fallbackQuestionCopy()`, `requestBirthTimeGuidePrompt()`, and `draftBirthTimeEvidence()`. -- Consumes only server-loaded `QuestionSpec` and current case identifiers; does not expose score/save/confirm/apply tools. - -- [ ] **Step 1: Add failing pure safety tests** - -```ts -test("draft parser cannot change the server-selected domain", () => { - assert.throws(() => parseEvidenceDraftOutput( - { domain: "relationship", precision: "month", date: "2023-04" }, - { requiredDomain: "career" }, - )); -}); - -test("ambiguous dates stay incomplete instead of being invented", () => { - const draft = parseEvidenceDraftOutput( - { domain: "career", precision: null, date: null }, - { requiredDomain: "career" }, - ); - assert.equal(draft.needsReview, true); - assert.equal(draft.date, null); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test tests/birth-time-guide-agent.test.ts tests/birth-time-guide-route.test.ts` - -Expected: FAIL because guide modules do not exist. - -- [ ] **Step 3: Implement the constrained guide agent** - -Agent instructions must require concise Simplified Chinese, one neutral question, no candidate-support disclosure, JSON-only drafts, no missing-date invention, and no astrology result. Register only a draft-structure tool; do not register consultation, scoring, candidate, profile, or confirmation tools. - -- [ ] **Step 4: Implement the authenticated no-credit route** - -Supported actions: - -```ts -type GuideRequest = - | { type: "render_question"; caseId: string } - | { type: "draft_evidence"; caseId: string; actionId: string; turnVersion: number; message: string }; -``` - -The route loads the owner-scoped current turn itself. `render_question` returns Agent copy or deterministic fallback. `draft_evidence` constrains extraction to the current question domain, then calls `proposeEvidenceDraft`; it never calls score/save/confirm/apply and never touches consultation credits. - -- [ ] **Step 5: Add source/contract assertions for the tool boundary** - -Assert that the guide route does not import `begin_consultation_credit`, `confirmBirthTimeCandidate`, `saveBirthTimeCandidate`, or the consultation Agent, and that fallback output is returned when no model is configured. - -- [ ] **Step 6: Run focused tests and verify GREEN** - -Run: `cd frontend && node --test tests/birth-time-guide-agent.test.ts tests/birth-time-guide-route.test.ts tests/birth-time-journey-client.test.ts` - -Expected: all guide safety tests pass. - -- [ ] **Step 7: Commit the Agent boundary** - -```bash -git add frontend/src/lib/birth-time-guide-agent.ts frontend/src/mastra/index.ts frontend/src/app/api/birth-time-guide/route.ts frontend/src/lib/birth-time-journey-client.ts frontend/tests/birth-time-guide-agent.test.ts frontend/tests/birth-time-guide-route.test.ts -git commit -m "feat: add constrained birth time guide agent" -``` - ---- - -### Task 7: One-Question Chat UI, Draft Confirmation, and Automatic Polling - -**Files:** -- Create: `frontend/src/components/birth-time-guide-turn.tsx` -- Create: `frontend/src/components/birth-time-evidence-draft-card.tsx` -- Modify: `frontend/src/components/birth-time-rectification.tsx` -- Modify: `frontend/src/components/birth-time-candidate-result.tsx` -- Modify: `frontend/src/app/page.tsx` -- Modify: `frontend/src/app/globals.css` -- Modify: `frontend/tests/birth-time-rectification-contract.test.ts` -- Create: `frontend/tests/birth-time-guide-flow.test.ts` - -**Interfaces:** -- Consumes: parsed `JourneyClientResponse.nextAction`, guide prompt/draft APIs, `confirmBirthTimeEvidenceDraft`, `skipBirthTimeEvidenceQuestion`, and `pollBirthTimeScoring`. -- Produces: one-question composer, review card, progress display, score-pending state, terminal low/medium/high result actions. - -- [ ] **Step 1: Add failing UI-flow contract tests** - -```ts -test("guided rectification renders one question and a natural-language composer", () => { - assert.match(turnSource, /journey\.nextAction\.kind === "ask_baseline_evidence"/); - assert.match(turnSource, /说出大概年份也可以/); - assert.doesNotMatch(rectificationSource, /questions\.slice\(0, 3\)/); -}); - -test("draft review is explicit and scoring starts from confirmation", () => { - assert.match(draftSource, /确认并用于校正/); - assert.match(pageSource, /confirmBirthTimeEvidenceDraft/); - assert.doesNotMatch(turnSource, /比较候选时间/); -}); - -test("score_pending polls automatically and resume renders the persisted action", () => { - assert.match(pageSource, /pollBirthTimeScoring/); - assert.match(pageSource, /nextAction\.kind === "score_pending"/); - assert.match(pageSource, /resumeBirthTimeJourney/); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test tests/birth-time-rectification-contract.test.ts tests/birth-time-guide-flow.test.ts` - -Expected: FAIL because guided components and handlers do not exist. - -- [ ] **Step 3: Implement focused Client Components** - -`BirthTimeGuideTurn` owns the one-question message input and skip action. `BirthTimeEvidenceDraftCard` owns editable domain-locked date/precision fields and the explicit confirm action. Keep candidate rendering in `BirthTimeCandidateResult`; do not put server transitions back into `page.tsx`. - -- [ ] **Step 4: Wire page state and automatic polling** - -When `nextAction` changes to an ask action, request Agent copy with a deterministic fallback already visible. When it changes to `score_pending`, start one bounded poll loop, cancel it on unmount/case/version change, and replace the whole Journey response on completion. Network failure leaves the persisted retry action visible. - -- [ ] **Step 5: Preserve legacy rendering only behind normalized responses** - -Remove the fixed three-question presentation from the active path. Existing legacy questionnaire fields may remain parsed for audit/migration, but `BirthTimeRectification` renders from `nextAction` only. - -- [ ] **Step 6: Add responsive styles using existing tokens** - -Use the existing card, type, color, spacing, focus, and 44px target tokens. Keep Chinese phrases such as “候选时间”, “当前排盘使用时间”, “关键经历”, and “确认并用于校正” phrase-safe at 390px. - -- [ ] **Step 7: Run focused tests, typecheck, and lint** - -Run: `cd frontend && node --test tests/birth-time-rectification-contract.test.ts tests/birth-time-guide-flow.test.ts && npx tsc --noEmit && npm run lint -- src/components/birth-time-guide-turn.tsx src/components/birth-time-evidence-draft-card.tsx src/components/birth-time-rectification.tsx src/app/page.tsx` - -Expected: tests, typecheck, and targeted lint pass. - -- [ ] **Step 8: Commit the UI** - -```bash -git add frontend/src/components/birth-time-guide-turn.tsx frontend/src/components/birth-time-evidence-draft-card.tsx frontend/src/components/birth-time-rectification.tsx frontend/src/components/birth-time-candidate-result.tsx frontend/src/app/page.tsx frontend/src/app/globals.css frontend/tests/birth-time-rectification-contract.test.ts frontend/tests/birth-time-guide-flow.test.ts -git commit -m "feat: guide birth time evidence one question at a time" -``` - ---- - -### Task 8: Complete Verification Suite, Real Flow QA, and Accuracy Boundary - -**Files:** -- Create: `frontend/tests/birth-time-agent-flow-e2e.test.ts` -- Create: `frontend/src/lib/birth-time-journey-telemetry.ts` -- Create: `frontend/tests/birth-time-journey-telemetry.test.ts` -- Modify: `frontend/src/app/api/birth-time-journey/route.ts` -- Modify: `frontend/src/app/api/birth-time-guide/route.ts` -- Modify: `frontend/DESIGN.md` -- Modify: `docs/superpowers/specs/2026-07-18-agent-guided-birth-time-rectification-design.md` only if implementation reveals a corrected contract; otherwise leave the committed spec unchanged. - -**Interfaces:** -- Consumes the complete feature. -- Produces a reusable regression test set and manual QA evidence for baseline, adaptive, low, medium, high, failure, and resume branches. - -- [ ] **Step 1: Add a fake-Agent/fake-engine full-flow test** - -```ts -test("agent-guided journey cannot dead-end or apply without high confirmation", async () => { - let turn = await harness.assess(approximateAssessment); - for (const event of baselineEvents) { - turn = await harness.draftAndConfirm(turn, event); - assert.ok(turn.nextAction); - } - turn = await harness.pollUntilSettled(turn); - while (turn.nextAction.kind === "ask_adaptive_evidence") { - turn = await harness.skip(turn); - assert.ok(turn.nextAction); - } - assert.ok(["present_low_result", "present_medium_result", "request_candidate_confirmation"].includes(turn.nextAction.kind)); - assert.equal(harness.profile.activeBirthTime, null); -}); -``` - -- [ ] **Step 2: Run all frontend journey tests** - -Run: `cd frontend && node --test tests/birth-time-*.test.ts` - -Expected: all birth-time tests pass with no skipped tests. - -- [ ] **Step 3: Run Python scoring and API tests** - -Run: `.venv/bin/python -m pytest -q tests/test_active_rectification_questions.py tests/test_active_rectification_events.py tests/test_active_rectification_api.py tests/test_birth_time_journey_contract.py` - -Expected: all selected Python tests pass. - -- [ ] **Step 4: Run full frontend verification** - -Run: `cd frontend && npm test && npx tsc --noEmit && npm run lint && npm run build` - -Expected: full tests, typecheck, lint, and production build pass. - -- [ ] **Step 5: Run manual browser scenarios** - -Verify at desktop and 390px mobile: - -1. baseline question → natural-language draft → edit → confirm → next question; -2. third baseline evidence → automatic calculation → adaptive question; -3. three low adaptive rounds → terminal saved range; -4. medium result → save only, no minute application; -5. high result → explicit representative-time confirmation → active profile time; -6. refresh on ask, draft, score-pending, retry, and confirmation states; -7. Agent unavailable fallback and scoring failure retry; -8. duplicate confirm does not duplicate evidence. - -- [ ] **Step 6: Run security and code review** - -Confirm the guide route has no billing/candidate/apply tool, job handles are owner-scoped and unguessable, raw event prose is not sent to the scorer or analytics, and low/medium confirmation attempts return conflict responses. - -- [ ] **Step 7: Add privacy-safe structured journey metrics** - -```ts -export type JourneyMetric = - | "turn_advanced" - | "draft_corrected" - | "journey_paused" - | "scoring_failed" - | "scoring_recovered" - | "illegal_snapshot"; - -export function journeyMetric(name: JourneyMetric, labels: { - phase: "baseline" | "adaptive" | "result"; - confidence?: "low" | "medium" | "high"; -}) { - console.info("[birth-time-journey]", JSON.stringify({ name, ...labels })); -} -``` - -Tests must prove the metric API has no field for raw message, event date, birth date, coordinates, case ID, or user ID. Route calls record state transitions and failures only. - -- [ ] **Step 8: Record the accuracy boundary in `frontend/DESIGN.md`** - -Document that the Agent controls wording only; confidence is a versioned internal deterministic gate and remains below external-oracle/real-case proof. - -- [ ] **Step 9: Commit the verification set** - -```bash -git add frontend/tests/birth-time-agent-flow-e2e.test.ts frontend/src/lib/birth-time-journey-telemetry.ts frontend/tests/birth-time-journey-telemetry.test.ts frontend/src/app/api/birth-time-journey/route.ts frontend/src/app/api/birth-time-guide/route.ts frontend/DESIGN.md -git commit -m "test: verify agent guided birth time journey" -``` - -## Execution Order and Subagent Ownership - -1. Tasks 1 and 2 may run in parallel because they own separate new modules; coordinate the shared adapter/service type before merging. -2. Task 3 follows Task 2. -3. Task 4 follows Tasks 1–3. -4. Task 5 follows Task 4. -5. Task 6 may start after Task 2 but must integrate only after Task 4. -6. Task 7 follows Tasks 4–6. -7. Task 8 runs only after all implementation tasks pass their focused tests. - -Each executor must state owned files, preserve other agents’ edits, capture RED and GREEN output, and hand back changed-file and test evidence. A separate reviewer checks spec compliance and code quality before the next dependent task begins. diff --git a/docs/superpowers/plans/2026-07-18-birth-time-evidence-rectification.md b/docs/superpowers/plans/2026-07-18-birth-time-evidence-rectification.md deleted file mode 100644 index 69466099..00000000 --- a/docs/superpowers/plans/2026-07-18-birth-time-evidence-rectification.md +++ /dev/null @@ -1,370 +0,0 @@ -# Birth-Time Evidence Rectification 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:** Continue the completed birth-time questionnaire into structured life-event scoring, candidate review, and guarded user confirmation. - -**Architecture:** Extend the pure TypeScript journey state machine first, then add one isolated Python event adjudicator behind the existing Jyotish API. The authenticated Next.js service owns persistence and confirmation; React renders only the server-returned input state. - -**Tech Stack:** Python 3.12, pytest, Next.js 16 App Router, React 19, TypeScript 5, Zod 3, Supabase/PostgreSQL, Node test runner. - -## Global Constraints - -- Agent prose never determines state, candidate score, confidence, route, or application permission. -- `reported_birth_time` is immutable; only a confirmed server-side result may update `active_birth_time` and legacy `birth_time`. -- Rectification events use the free journey route and never call `/api/consult`. -- Life-event dates and domains are structured; free-form text never affects scoring. -- Low and medium results cannot apply a time. High results require an explicit confirmation event. -- The UI must say “候选时间” or “当前排盘使用时间”, never “真实出生时间”. -- Preserve unrelated dirty-worktree changes and do not reset the repository. - ---- - -### Task 1: Deterministic Journey State and Event Contracts - -**Files:** -- Modify: `frontend/src/lib/birth-time-journey.ts` -- Test: `frontend/tests/birth-time-journey.test.ts` - -**Interfaces:** -- Produces: `lifeEventSchema`, `candidateResultSchema`, extended `JourneySnapshot`, `withCompletedQuestionnaire()`, `withCandidateResult()`, and `withConfirmedCandidate()`. -- Consumes: existing `JourneySnapshot` and `RectificationScoring`. - -- [ ] **Step 1: Write failing state-transition tests** - -```ts -test("the final questionnaire answer requests dated life events", () => { - const next = withRectificationScoring(initial, { - answeredCount: 8, - candidateClusterRankings: [{ cluster: "middle_candidate_cluster", score: 5 }], - nextRoundQuestions: [], - }); - assert.equal(next.input, "life_events"); - assert.equal(next.assistantIntent, "collect_dated_life_events"); - assert.equal(next.canApply, false); -}); - -test("only a high candidate enters confirmation", () => { - assert.equal(withCandidateResult(eventSnapshot, mediumResult).input, "candidate_actions"); - assert.equal(withCandidateResult(eventSnapshot, highResult).input, "candidate_confirmation"); -}); -``` - -- [ ] **Step 2: Run the focused test and verify RED** - -Run: `cd frontend && node --test tests/birth-time-journey.test.ts` - -Expected: FAIL because the new input, intent, schemas, and transition functions do not exist. - -- [ ] **Step 3: Implement the minimal typed state model** - -```ts -export const lifeEventSchema = z.object({ - id: z.string().uuid(), - domain: z.enum(["education", "relocation", "relationship", "career", "health_pressure"]), - date: z.string(), - precision: z.enum(["year", "month", "day"]), -}).strict().readonly(); - -export function withCandidateResult(snapshot: JourneySnapshot, result: CandidateResult): JourneySnapshot { - switch (result.confidence) { - case "low": return { ...snapshot, state: "rectifying", input: "life_events", canApply: false }; - case "medium": return { ...snapshot, state: "candidate", input: "candidate_actions", canApply: false }; - case "high": return { ...snapshot, state: "confirming", input: "candidate_confirmation", canApply: true }; - default: return assertNever(result.confidence); - } -} -``` - -- [ ] **Step 4: Run the focused test and verify GREEN** - -Run: `cd frontend && node --test tests/birth-time-journey.test.ts` - -Expected: all birth-time journey domain tests pass. - ---- - -### Task 2: Local Candidate Event Adjudicator - -**Files:** -- Create: `scripts/active_rectification_events.py` -- Test: `tests/test_active_rectification_events.py` - -**Interfaces:** -- Produces: `score_life_events(request: RectificationEventRequest) -> CandidateResult`. -- Consumes: stored birth date/range/location, structured events, `domain_calculation_service`, `varga`, `jaimini`, Vimshottari timeline, and Narayana Dasha. - -- [ ] **Step 1: Write failing Python tests for segments, thresholds, and abstention** - -```python -def test_high_confidence_requires_four_events_three_domains_and_narrow_leader() -> None: - result = score_life_events(high_fixture()) - assert result["confidence"] == "high" - assert result["winning_segment"]["start_time"] <= result["winning_segment"]["end_time"] - assert result["can_apply"] is True - -def test_tied_candidates_abstain() -> None: - result = score_life_events(tied_fixture()) - assert result["confidence"] == "low" - assert "tied_leader" in result["reasons"] - assert result["can_apply"] is False -``` - -- [ ] **Step 2: Run the focused test and verify RED** - -Run: `.venv/bin/python -m pytest -q tests/test_active_rectification_events.py` - -Expected: collection fails because `active_rectification_events` does not exist. - -- [ ] **Step 3: Implement frozen request models and fixed rule tables** - -```python -class LifeEvent(TypedDict): - id: str - domain: EventDomain - date: str - precision: EventPrecision - -PRECISION_WEIGHTS: Final = {"day": 1.0, "month": 0.8, "year": 0.5} -DOMAIN_LAYERS: Final = { - "education": ("D24", (4, 5, 9)), - "relocation": ("D4", (4, 12)), - "relationship": ("D9", (7,)), - "career": ("D10", (10,)), - "health_pressure": ("D30", (6, 8, 12)), -} -``` - -- [ ] **Step 4: Implement minute scanning, contiguous signature segments, dual-Dasha rule IDs, and confidence thresholds** - -Every candidate row records actual D1/D4/D9/D10/D24/D30 data. Adjacent equal signatures collapse into segments. Each event score emits rule IDs for Vimshottari lord/domain-house, Narayana sign/domain-house, and domain-Varga support; unavailable mandatory layers produce a low-confidence abstention. - -- [ ] **Step 5: Run the focused test and verify GREEN** - -Run: `.venv/bin/python -m pytest -q tests/test_active_rectification_events.py` - -Expected: all event adjudicator tests pass without network access. - ---- - -### Task 3: Python API Boundary - -**Files:** -- Modify: `scripts/jyotish_api_server.py` -- Modify: `tests/test_active_rectification_api.py` - -**Interfaces:** -- Produces: `POST /api/active_rectification_events`. -- Consumes: `score_life_events()` from Task 2. - -- [ ] **Step 1: Add a failing API contract test** - -```python -def test_active_rectification_events_api_scores_structured_events() -> None: - result = _handler()._compute_active_rectification_events(valid_payload()) - assert result["success"] is True - assert result["endpoint"] == "active_rectification_events" - assert result["candidate_result_id"] -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `.venv/bin/python -m pytest -q tests/test_active_rectification_api.py -k events` - -Expected: FAIL because the handler and route are missing. - -- [ ] **Step 3: Add strict payload parsing and the new handler route** - -The HTTP method validates date/range/location and three-to-six events before calling `score_life_events`; invalid variants raise `BadRequest`. Register the path in POST dispatch, endpoint catalog, and capability metadata. - -- [ ] **Step 4: Run and verify GREEN** - -Run: `.venv/bin/python -m pytest -q tests/test_active_rectification_api.py` - -Expected: all active rectification API tests pass. - ---- - -### Task 4: Journey Service, Engine, Store, and SQL - -**Files:** -- Modify: `frontend/src/lib/birth-time-journey-service.ts` -- Modify: `frontend/src/lib/birth-time-journey-engine.ts` -- Modify: `frontend/src/lib/birth-time-journey-adapters.ts` -- Modify: `frontend/src/lib/birth-time-journey-store.ts` -- Create: `frontend/supabase/migrations/20260718010000_birth_time_evidence_rectification.sql` -- Modify: `frontend/tests/birth-time-journey-service.test.ts` -- Modify: `frontend/tests/birth-time-journey-adapters.test.ts` -- Modify: `tests/test_birth_time_journey_contract.py` - -**Interfaces:** -- Produces: engine `scoreEvents`, store `saveCandidateResult`/`confirmCandidate`, service `submitLifeEvents`/`saveCandidate`/`confirmCandidate`. -- Consumes: Task 1 schemas and Task 3 API. - -- [ ] **Step 1: Write failing service tests** - -```ts -test("submitting life events persists the server result", async () => { - const result = await service.submitLifeEvents("user-1", "case-1", events); - assert.equal(result.candidateResult?.confidence, "medium"); - assert.equal(result.snapshot.input, "candidate_actions"); -}); - -test("confirmation rejects a stale result id", async () => { - await assert.rejects( - service.confirmCandidate("user-1", "case-1", "stale", "14:24"), - StaleCandidateConfirmationError, - ); -}); -``` - -- [ ] **Step 2: Run focused frontend tests and verify RED** - -Run: `cd frontend && node --test tests/birth-time-journey-service.test.ts tests/birth-time-journey-adapters.test.ts` - -Expected: FAIL because the new ports and methods are missing. - -- [ ] **Step 3: Implement engine adapter and service transitions** - -`scoreEvents` posts only stored assessment/range/location plus parsed events. `submitLifeEvents` reloads the owner-scoped case, computes the result, transitions through `withCandidateResult`, and persists atomically. `confirmCandidate` rechecks state, result ID, confidence, time, and ownership before store confirmation. - -- [ ] **Step 4: Write the failing SQL contract assertions and verify RED** - -Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py` - -Expected: FAIL because event/result columns and grants are absent. - -- [ ] **Step 5: Add the migration and store persistence** - -The migration adds `life_events`, `candidate_result`, `event_scoring_version`, `candidate_result_id`, `candidate_saved_at`, and `confirming` status. Browser grants exclude score/result/confirmation writes. The admin store updates profile active time only inside `confirmCandidate`. - -- [ ] **Step 6: Run service, adapter, and SQL tests and verify GREEN** - -Run: `cd frontend && node --test tests/birth-time-journey-service.test.ts tests/birth-time-journey-adapters.test.ts && cd .. && .venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py` - -Expected: all selected tests pass. - ---- - -### Task 5: Route and Client Boundary - -**Files:** -- Modify: `frontend/src/app/api/birth-time-journey/route.ts` -- Modify: `frontend/src/lib/birth-time-journey-client.ts` -- Modify: `frontend/tests/birth-time-journey-client.test.ts` - -**Interfaces:** -- Produces: client calls `submitBirthTimeLifeEvents`, `saveBirthTimeCandidate`, `confirmBirthTimeCandidate`. -- Consumes: Task 4 service methods. - -- [ ] **Step 1: Write failing parser and request tests** - -```ts -test("client accepts only a guarded high-confirmation response", () => { - const parsed = parseJourneyResponse(highConfirmationResponse); - assert.equal(parsed.snapshot.input, "candidate_confirmation"); -}); - -test("client rejects a rectification response that applies without confirmation state", () => { - assert.throws(() => parseJourneyResponse(unsafeResponse)); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test tests/birth-time-journey-client.test.ts` - -Expected: FAIL on missing response fields and client functions. - -- [ ] **Step 3: Extend strict event schemas and exhaustive route dispatch** - -The route accepts only `submit_life_events`, `save_candidate`, and `confirm_candidate` shapes defined by Zod `.strict()`. It maps stale confirmation and insufficient evidence to 409 and keeps all server failures fail-closed. - -- [ ] **Step 4: Run and verify GREEN** - -Run: `cd frontend && node --test tests/birth-time-journey-client.test.ts` - -Expected: all client boundary tests pass. - ---- - -### Task 6: Life-Event and Candidate UI - -**Files:** -- Create: `frontend/src/components/birth-time-life-events.tsx` -- Create: `frontend/src/components/birth-time-candidate-result.tsx` -- Modify: `frontend/src/components/birth-time-rectification.tsx` -- Modify: `frontend/src/app/page.tsx` -- Modify: `frontend/src/app/globals.css` -- Modify: `frontend/DESIGN.md` -- Modify: `frontend/tests/birth-time-rectification-contract.test.ts` - -**Interfaces:** -- Produces: accessible event form and low/medium/high result actions. -- Consumes: Task 5 client calls and parsed `JourneyClientResponse`. - -- [ ] **Step 1: Add failing UI contract assertions** - -```ts -test("rectification renders the life-event step from the server input", () => { - assert.match(component, /snapshot\.input === "life_events"/); - assert.match(component, /BirthTimeLifeEvents/); - assert.match(component, /BirthTimeCandidateResult/); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test tests/birth-time-rectification-contract.test.ts` - -Expected: FAIL because the new components and input branches are missing. - -- [ ] **Step 3: Document and implement the component states** - -Extend the existing Birth time intake component in `DESIGN.md` with life-event rows, candidate action states, and confirmation copy. Implement three initial event rows, a six-row maximum, persistent labels, precision-dependent native inputs, live errors, and existing design tokens only. - -- [ ] **Step 4: Wire page handlers and ready transition** - -`page.tsx` submits events, saves candidates, and confirms only through Task 5 client functions. A ready response updates profile state and proceeds to existing onboarding; no client code sets `activeTime` or `canApply`. - -- [ ] **Step 5: Run and verify GREEN** - -Run: `cd frontend && node --test tests/birth-time-rectification-contract.test.ts` - -Expected: the full UI contract passes. - ---- - -### Task 7: Final Verification and Browser QA - -**Files:** -- Review all files changed in Tasks 1–6. - -**Interfaces:** -- Produces: fresh automated and browser evidence for the approved design. - -- [ ] **Step 1: Run focused Python and frontend tests** - -Run: `.venv/bin/python -m pytest -q tests/test_active_rectification_events.py tests/test_active_rectification_api.py tests/test_birth_time_journey_contract.py` - -Run: `cd frontend && npm test` - -Expected: zero failures. - -- [ ] **Step 2: Run lint, TypeScript, and production build** - -Run: `cd frontend && npm run lint && npx tsc --noEmit && npm run build` - -Expected: exit code 0 for every command. - -- [ ] **Step 3: Run no-excuse checks on changed Python and TypeScript sources** - -Run the programming skill checkers against the changed source files and repair every new violation without refactoring unrelated legacy code. - -- [ ] **Step 4: Run real-browser visual QA** - -Start the verified application, drive the final questionnaire answer, event validation, low/medium/high candidate cards, and confirmation fixture at 375px, 768px, and 1280px. Verify keyboard labels, live regions, overflow, motion, and that no consultation request is issued before ready. - -- [ ] **Step 5: Review the final diff** - -Confirm every spec section has implementation evidence, `reported_birth_time` remains immutable, client payloads cannot inject score/confidence/application fields, and unrelated dirty files were not overwritten. diff --git a/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md b/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md deleted file mode 100644 index 62fb9d00..00000000 --- a/docs/superpowers/plans/2026-07-18-dynamic-choice-birth-time-rectification.md +++ /dev/null @@ -1,1388 +0,0 @@ -# Dynamic-Choice Birth-Time Rectification 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:** Replace the fixed five-domain, fixed-round, text-draft birth-time flow with a model-generated one-question-at-a-time choice flow whose candidate scoring, stopping decisions, persistence, and application permissions remain deterministic and server-owned. - -**Architecture:** The Python Jyotish engine computes minute candidates, date-window opportunities, candidate partitions, information gain, and versioned scores. A constrained Mastra Agent may select one server-issued opportunity and write neutral Simplified Chinese question/option labels, while a TypeScript validator binds those labels to server-issued partition IDs. `BirthTimeJourney` persists the complete internal question, accepts only `questionId + optionId` from the client, drives scoring and stop policy, and makes terminal states irreversible within the same case. - -**Tech Stack:** Python 3.11+, TypeScript 5, Zod 3, Next.js 16.2 Route Handlers, React 19, Mastra 1.50, Supabase/PostgreSQL, Node test runner, pytest, Playwright visual QA. - -## Global Constraints - -- Preserve the dirty worktree. Never reset, restore, overwrite, or stage unrelated user changes. -- New assessments use protocol `dynamic-choice-v2`; existing fixed-question fields remain read-only legacy audit data. -- The UI never displays a fixed total question count or an adaptive round number. -- The deterministic engine may use a finite registry of scoreable experience dimensions, but v2 has no “ask every domain” checklist: opportunity gain may skip a dimension, revisit a different partition in one dimension, or stop before any nominal coverage target. -- Each generated question has 2–4 primary choices plus server-added `不确定 / 不记得` and `都不符合` choices. -- A primary choice submits immediately; it never creates a date draft, precision selector, or second confirmation screen. -- `都不符合` may collect at most 240 characters of optional context. That text is never scored directly. -- The model cannot create candidate minutes, partitions, weights, scores, confidence, progress, permissions, or application commands. -- The browser submits only `caseId`, `actionId`, `turnVersion`, `questionId`, and `optionId`; it never receives or submits a `partitionId`. -- Stop on high confidence, no useful opportunity, two consecutive effective plateaus, repeated question/partition fingerprints, explicit user finish, unrecoverable generation fallback, or 10 effective answers. -- `present_low_result`, `present_medium_result`, and `ready` are terminal for their existing `caseId`; resume cannot generate another question. -- Low and medium confidence can save a candidate range only. Only an explicitly confirmed high-confidence candidate may update `active_birth_time`. -- `reported_birth_time` is immutable. -- Keep all scoring thresholds in a versioned deterministic module; prompts and client parameters cannot override them. -- Read `frontend/node_modules/next/dist/docs/01-app/01-getting-started/15-route-handlers.md` and `05-server-and-client-components.md` before changing Route Handlers or server/client component boundaries. -- Add no runtime dependency. -- Every task uses red → green TDD and ends with a focused commit containing only that task's files. - -## File Responsibility Map - -New focused files: - -- `scripts/dynamic_rectification.py`: candidate-window opportunity generation and deterministic choice scoring. -- `frontend/src/lib/birth-time-dynamic-choice.ts`: browser-safe public question, option, and range schemas. -- `frontend/src/lib/birth-time-dynamic-choice-internal.ts`: server-only opportunities, private partition mappings, answers, evidence, and control state. -- `frontend/src/lib/birth-time-dynamic-stop-policy.ts`: pure stop/continue decision and plateau calculation. -- `frontend/src/lib/birth-time-dynamic-question-validator.ts`: bind model labels to server opportunities and add special options. -- `frontend/src/lib/birth-time-dynamic-transitions.ts`: pure v2 Journey transitions. -- `frontend/src/lib/birth-time-dynamic-actions.ts`: authenticated/idempotent v2 mutations. -- `frontend/src/lib/birth-time-dynamic-scoring-service.ts`: claim, execute, and complete v2 score jobs. -- `frontend/src/components/birth-time-choice-question.tsx`: click-first question and optional unmatched note UI. - -Existing files retain these roles: - -- `scripts/active_rectification_questions.py` and `active_rectification_scoring.py`: legacy fixed-question audit behavior only. -- `frontend/src/lib/birth-time-guide-agent.ts`: constrained question-generation request/output contract. -- `frontend/src/lib/birth-time-guide-service.ts`: generate and persist a v2 question; it does not score. -- `frontend/src/lib/birth-time-journey-service.ts`: protocol routing and public journey response orchestration. -- `frontend/src/lib/birth-time-journey-turn-protocol.ts`: public `NextAction` and progress protocol. -- `frontend/src/lib/birth-time-journey-turn-persistence.ts`: load public case state plus service-role-only v2 private state and save both atomically through RPCs. -- `frontend/src/hooks/use-birth-time-guided-journey.ts`: browser coordination only; no scoring or stop decisions. - ---- - -### Task 1: Dynamic Choice Contracts and Stop Policy - -**Files:** -- Create: `frontend/src/lib/birth-time-dynamic-choice.ts` -- Create: `frontend/src/lib/birth-time-dynamic-choice-internal.ts` -- Create: `frontend/src/lib/birth-time-dynamic-stop-policy.ts` -- Modify: `frontend/src/lib/birth-time-journey-turn-protocol.ts` -- Modify: `frontend/src/lib/birth-time-journey-turn.ts` -- Test: `frontend/tests/birth-time-dynamic-choice.test.ts` -- Test: `frontend/tests/birth-time-dynamic-stop-policy.test.ts` - -**Interfaces:** -- Produces `CandidateDifferencePacket`, `QuestionOpportunity`, `PersistedDynamicChoiceQuestion`, `PublicDynamicChoiceQuestion`, `StoredChoiceAnswer`, and `DynamicControlState`. -- Produces `decideDynamicStop(input: DynamicStopInput): DynamicStopDecision`. -- Replaces fixed `ask_baseline_evidence` / `ask_adaptive_evidence` in v2 with `generate_dynamic_question`, `ask_dynamic_choice`, and `clarify_unmatched_answer`. - -- [ ] **Step 1: Write failing schema tests** - -```ts -test("public questions never expose partition ids", () => { - const parsed = publicDynamicChoiceQuestionSchema.parse({ - questionId: "11111111-1111-4111-8111-111111111111", - prompt: "哪一个时间段更接近这次工作变化?", - options: [ - { optionId: "22222222-2222-4222-8222-222222222222", label: "2018—2020 年", kind: "primary" }, - { optionId: "33333333-3333-4333-8333-333333333333", label: "2021—2023 年", kind: "primary" }, - { optionId: "44444444-4444-4444-8444-444444444444", label: "不确定 / 不记得", kind: "unknown" }, - { optionId: "55555555-5555-4555-8555-555555555555", label: "都不符合", kind: "unmatched" }, - ], - }); - assert.equal("partitionId" in parsed.options[0], false); - assert.equal(publicDynamicChoiceQuestionSchema.safeParse({ - ...parsed, - options: [{ ...parsed.options[0], partitionId: "private" }, ...parsed.options.slice(1)], - }).success, false); -}); - -test("internal primary choices require a server partition", () => { - assert.equal(persistedDynamicChoiceQuestionSchema.safeParse(internalQuestion).success, true); - assert.equal(persistedDynamicChoiceQuestionSchema.safeParse({ - ...internalQuestion, - options: internalQuestion.options.map((option) => option.kind === "primary" - ? { optionId: option.optionId, label: option.label, kind: option.kind, partitionId: null } - : option), - }).success, false); -}); -``` - -- [ ] **Step 2: Run the contracts test and verify RED** - -Run: `cd frontend && node --test tests/birth-time-dynamic-choice.test.ts` - -Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `birth-time-dynamic-choice.ts`. - -- [ ] **Step 3: Add strict public/internal schemas** - -Add the browser-safe shapes to `birth-time-dynamic-choice.ts` and the partition-bearing shapes to `birth-time-dynamic-choice-internal.ts`. Do not add a `server-only` package dependency: this repository does not currently install that marker and the plan forbids new runtime dependencies. Enforce the boundary with strict public projection plus a source-contract test proving no component, hook, client transport, or public response schema imports `birth-time-dynamic-choice-internal.ts`. - -```ts -export type PublicChoiceKind = "primary" | "unknown" | "unmatched"; - -export type TimeRange = { readonly startTime: string; readonly endTime: string }; - -export type PublicDynamicChoiceQuestion = { - readonly questionId: string; - readonly prompt: string; - readonly options: readonly { - readonly optionId: string; - readonly label: string; - readonly kind: PublicChoiceKind; - }[]; -}; -``` - -Use these exact server-only shapes: - -```ts -import type { CandidateResult } from "./birth-time-evidence.ts"; -import type { PublicChoiceKind, PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts"; - -export type EvidencePartition = { - readonly partitionId: string; - readonly descriptor: string; - readonly fallbackLabel: string; -}; - -export type ScoredEvidencePartition = EvidencePartition & { - readonly candidateScores: Readonly>; -}; - -export type QuestionOpportunity = { - readonly opportunityId: string; - readonly dimensionCode: string; - readonly neutralContext: string; - readonly estimatedInformationGain: number; - readonly candidatePartitionFingerprint: string; - readonly fallbackPrompt: string; - readonly partitions: readonly EvidencePartition[]; -}; - -export type CandidateDifferencePacket = { - readonly caseId: string; - readonly scoringVersion: "birth-time-choice-scoring-v2"; - readonly currentRange: TimeRange; - readonly opportunities: readonly QuestionOpportunity[]; - readonly askedQuestionFingerprints: readonly string[]; - readonly candidatePartitionFingerprints: readonly string[]; - readonly recentRangeHistory: readonly TimeRange[]; -}; - -export type CandidateDifferenceBuild = { - readonly packet: CandidateDifferencePacket; - readonly candidateModel: Readonly>; - readonly scoringPartitions: Readonly>; -}; - -export type PersistedDynamicChoiceQuestion = PublicDynamicChoiceQuestion & { - readonly opportunityId: string; - readonly dimensionCode: string; - readonly estimatedInformationGain: number; - readonly scoringVersion: string; - readonly source: "agent" | "fallback"; - readonly questionFingerprint: string; - readonly candidatePartitionFingerprint: string; - readonly options: readonly { - readonly optionId: string; - readonly label: string; - readonly kind: PublicChoiceKind; - readonly partitionId: string | null; - readonly candidateScores: Readonly> | null; - }[]; -}; - -export type StoredChoiceAnswer = { - readonly questionId: string; - readonly optionId: string; - readonly kind: PublicChoiceKind; - readonly opportunityId: string; - readonly answeredAt: string; -}; - -export type ServerChoiceEvidence = { - readonly questionId: string; - readonly opportunityId: string; - readonly partitionId: string; - readonly dimensionCode: string; - readonly candidateScores: Readonly>; - readonly informationGain: number; -}; - -export type DynamicChoiceScoringResult = { - readonly candidate: CandidateResult; - readonly evidenceMode: "dynamic_choice"; - readonly effectiveAnswerCount: number; - readonly dimensionCount: number; -}; - -export type PausedDynamicAction = - | { readonly kind: "generate_dynamic_question" } - | { readonly kind: "ask_dynamic_choice"; readonly questionId: string } - | { readonly kind: "clarify_unmatched_answer"; readonly questionId: string } - | { readonly kind: "retry_question_generation" } - | { readonly kind: "score_pending"; readonly jobId: string } - | { readonly kind: "retry_scoring"; readonly jobId: string }; - -export type DynamicControlState = { - readonly asOfDate: string; - readonly answeredCount: number; - readonly effectiveAnswerCount: number; - readonly plateauCount: number; - readonly questionFingerprints: readonly string[]; - readonly partitionFingerprints: readonly string[]; - readonly dismissedOpportunityIds: readonly string[]; - readonly recentRanges: readonly TimeRange[]; - readonly pausedAction: PausedDynamicAction | null; -}; -``` - -Use `.strict().readonly()` Zod objects. Enforce exactly 2–4 `primary`, exactly one `unknown`, exactly one `unmatched`, unique `optionId`, and nonempty labels up to 80 characters. Primary choices require a nonempty `partitionId` and finite `candidateScores`; both special choices require `partitionId === null` and `candidateScores === null`. - -- [ ] **Step 4: Write failing stop-policy tests** - -```ts -test("two effective unchanged scores stop without starting another question", () => { - const decision = decideDynamicStop({ - result: mediumCandidate, - effectiveAnswer: true, - previousResult: mediumCandidate, - priorPlateauCount: 1, - usefulOpportunityCount: 3, - repeatedOnly: false, - effectiveAnswerCount: 6, - }); - assert.deepEqual(decision, { kind: "finish", reason: "plateau", plateauCount: 2 }); -}); - -test("unknown answers do not advance plateau or the effective safety count", () => { - const decision = decideDynamicStop({ - result: lowCandidate, - effectiveAnswer: false, - previousResult: lowCandidate, - priorPlateauCount: 1, - usefulOpportunityCount: 2, - repeatedOnly: false, - effectiveAnswerCount: 4, - }); - assert.deepEqual(decision, { kind: "continue", plateauCount: 1 }); -}); - -test("terminal conditions are deterministic", () => { - assert.equal(decisionFor({ result: null, forcedReason: "user_finished" }).reason, "user_finished"); - assert.equal(decisionFor({ result: null, forcedReason: "generation_unavailable" }).reason, "generation_unavailable"); - assert.equal(decisionFor({ confidence: "high" }).reason, "high_confidence"); - assert.equal(decisionFor({ usefulOpportunityCount: 0 }).reason, "no_information_gain"); - assert.equal(decisionFor({ repeatedOnly: true }).reason, "repeated_partition"); - assert.equal(decisionFor({ effectiveAnswerCount: 10 }).reason, "safety_cap"); -}); -``` - -- [ ] **Step 5: Run the stop-policy test and verify RED** - -Run: `cd frontend && node --test tests/birth-time-dynamic-stop-policy.test.ts` - -Expected: FAIL because `decideDynamicStop` does not exist. - -- [ ] **Step 6: Implement deterministic stop ordering** - -`DynamicStopInput.result` is nullable so generation can stop safely before a first score. Add `forcedReason: "user_finished" | "generation_unavailable" | null`; these explicit terminal events are checked before score-derived conditions. Use this decision order: - -```ts -export function decideDynamicStop(input: DynamicStopInput): DynamicStopDecision { - const plateauCount = input.effectiveAnswer && input.result - ? materiallyChanged(input.previousResult, input.result) ? 0 : input.priorPlateauCount + 1 - : input.priorPlateauCount; - if (input.forcedReason) return { kind: "finish", reason: input.forcedReason, plateauCount }; - if (input.result?.confidence === "high") return { kind: "finish", reason: "high_confidence", plateauCount }; - if (input.effectiveAnswerCount >= 10) return { kind: "finish", reason: "safety_cap", plateauCount }; - if (plateauCount >= 2) return { kind: "finish", reason: "plateau", plateauCount }; - if (input.usefulOpportunityCount === 0) return { kind: "finish", reason: "no_information_gain", plateauCount }; - if (input.repeatedOnly) return { kind: "finish", reason: "repeated_partition", plateauCount }; - return { kind: "continue", plateauCount }; -} -``` - -`materiallyChanged()` returns true when the winning range start/end changes, the winning representative changes, or the margin changes by at least 2 percentage points. - -- [ ] **Step 7: Replace the public v2 progress/action shapes** - -Add these variants without deleting the legacy parser path yet: - -```ts -type DynamicNextAction = - | { readonly kind: "generate_dynamic_question" } - | { readonly kind: "ask_dynamic_choice"; readonly question: PublicDynamicChoiceQuestion } - | { readonly kind: "clarify_unmatched_answer"; readonly questionId: string } - | { readonly kind: "retry_question_generation" } - | { readonly kind: "score_pending"; readonly jobId: string } - | { readonly kind: "retry_scoring"; readonly jobId: string } - | { readonly kind: "present_low_result"; readonly resultId: string | null } - | { readonly kind: "present_medium_result"; readonly resultId: string } - | { readonly kind: "request_candidate_confirmation"; readonly resultId: string } - | { readonly kind: "ready"; readonly activeTime: string } - | { readonly kind: "paused" }; - -type DynamicJourneyProgress = { - readonly phase: "question" | "clarification" | "scoring" | "result" | "ready" | "paused"; - readonly answeredCount: number; - readonly effectiveAnswerCount: number; - readonly currentRange: TimeRange; - readonly previousRange: TimeRange | null; - readonly plateauCount: number; -}; -``` - -Do not expose the hidden safety count or a maximum question count in either schema. - -Add `dynamicJourneyTurnStateSchema` with `journeyProtocol: z.literal("dynamic-choice-v2")`, nonnegative `turnVersion`, `dynamicNextActionSchema`, `dynamicJourneyProgressSchema`, and the existing derived permissions schema. Keep `journeyTurnStateSchema` unchanged as the legacy-guided-v1 compatibility contract. Terminal resume behavior is implemented by Task 6 transitions, but every v2 public response must parse through this explicit dynamic turn-state discriminator. - -- [ ] **Step 8: Run focused tests and commit** - -Run: `cd frontend && node --test tests/birth-time-dynamic-choice.test.ts tests/birth-time-dynamic-stop-policy.test.ts tests/birth-time-journey-turn.test.ts` - -Expected: all selected tests pass. - -```bash -git add frontend/src/lib/birth-time-dynamic-choice.ts frontend/src/lib/birth-time-dynamic-choice-internal.ts frontend/src/lib/birth-time-dynamic-stop-policy.ts frontend/src/lib/birth-time-journey-turn-protocol.ts frontend/src/lib/birth-time-journey-turn.ts frontend/tests/birth-time-dynamic-choice.test.ts frontend/tests/birth-time-dynamic-stop-policy.test.ts -git commit -m "feat: define dynamic birth time choice protocol" -``` - ---- - -### Task 2: Deterministic Candidate Opportunities and Choice Scoring - -**Files:** -- Create: `scripts/dynamic_rectification.py` -- Create: `scripts/dynamic_rectification_opportunities.py` -- Modify: `scripts/jyotish_api_server.py:1280-1325,1735-1755,6766-6890,7645-7660,7770-7790` -- Test: `tests/test_dynamic_rectification.py` -- Test: `tests/test_dynamic_rectification_scoring.py` -- Modify: `tests/test_active_rectification_api.py` - -**Interfaces:** -- Produces `build_difference_packet(request) -> dict` and `score_choice_evidence(request) -> dict`. -- Adds `POST /api/dynamic_rectification_opportunities` and `POST /api/dynamic_rectification_score`. -- Keeps `/api/active_rectification_questions`, `/api/active_rectification_score`, and `/api/active_rectification_events` unchanged for legacy cases. - -- [ ] **Step 1: Write failing opportunity tests** - -```python -def test_packet_contains_only_candidate_backed_high_gain_opportunities(monkeypatch): - monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", fake_rows) - packet = dynamic_rectification.build_difference_packet(base_request()) - assert packet["scoring_version"] == "birth-time-choice-scoring-v2" - assert packet["current_range"] == {"start_time": "05:30", "end_time": "06:00"} - assert len(packet["opportunities"]) >= 1 - for opportunity in packet["opportunities"]: - assert opportunity["estimated_information_gain"] >= 0.15 - assert 2 <= len(opportunity["partitions"]) <= 4 - assert len({item["partition_id"] for item in opportunity["partitions"]}) == len(opportunity["partitions"]) - -def test_packet_excludes_used_opportunity_and_partition_fingerprints(monkeypatch): - monkeypatch.setattr(dynamic_rectification, "_candidate_window_rows", fake_rows) - first = dynamic_rectification.build_difference_packet(base_request()) - used = first["opportunities"][0] - request = base_request() - request["dismissed_opportunity_ids"] = [used["opportunity_id"]] - request["partition_fingerprints"] = [used["candidate_partition_fingerprint"]] - second = dynamic_rectification.build_difference_packet(request) - assert all(item["opportunity_id"] != used["opportunity_id"] for item in second["opportunities"]) - assert all(item["candidate_partition_fingerprint"] != used["candidate_partition_fingerprint"] for item in second["opportunities"]) - -def test_packet_reuses_the_persisted_candidate_model(monkeypatch): - calls = [] - monkeypatch.setattr(dynamic_rectification, "_compute_candidate_model", lambda request: calls.append(request) or fake_model()) - first = dynamic_rectification.build_difference_packet(base_request()) - second = dynamic_rectification.build_difference_packet({ - **base_request(), "candidate_model": first["candidate_model"], - }) - assert len(calls) == 1 - assert second["candidate_model"] == first["candidate_model"] -``` - -- [ ] **Step 2: Run opportunity tests and verify RED** - -Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k packet` - -Expected: FAIL with `ImportError: cannot import name 'dynamic_rectification'`. - -- [ ] **Step 3: Generate candidate-backed date-window opportunities** - -Use minute candidates from the submitted range, the existing local chart engine, D4/D9/D10/D24/D30, Vimshottari, and Narayana Dasha. For each supported experience dimension, evaluate bounded calendar windows from age 12 through the persisted `as_of_date`. Compute each candidate chart once, then reuse it across every dimension/window. Return a compact versioned `candidate_model` containing only candidate activation numbers needed for later opportunity ranking; a subsequent request must validate and reuse that model instead of recalculating charts. A candidate joins the partition for the window with its strongest domain activation; discard opportunities with fewer than two populated partitions or normalized entropy below `0.15`. - -Treat an overnight range as one chronological sequence: `23:59` and `00:00` are adjacent candidates. Bind every reusable candidate model to the exact birth date, persisted `as_of_date`, start/end range, latitude, longitude, and timezone; location fields are required and never default to zero. Keep the public entrypoints/scoring in `dynamic_rectification.py` and extract candidate-model/opportunity helpers to `dynamic_rectification_opportunities.py` so production and test files stay within the repository's 250 pure-LOC limit. - -The exact opportunity contract is: - -```python -class EvidencePartition(TypedDict): - partition_id: str - descriptor: str - fallback_label: str - candidate_scores: dict[str, float] - -class QuestionOpportunity(TypedDict): - opportunity_id: str - dimension_code: str - neutral_context: str - estimated_information_gain: float - candidate_partition_fingerprint: str - fallback_prompt: str - partitions: list[EvidencePartition] -``` - -`candidate_scores` keys are `HH:MM` candidates inside the current range. IDs and fingerprints are SHA-256 hashes of canonical JSON containing scoring version, dimension, window boundaries, and sorted candidate memberships. Never use prose in a fingerprint. - -- [ ] **Step 4: Write failing deterministic scoring tests** - -```python -def test_primary_choice_changes_rankings_and_returns_a_real_range(): - result = dynamic_rectification.score_choice_evidence({ - **score_request(), - "choice_evidence": [{ - "question_id": str(uuid4()), - "opportunity_id": "career-window", - "partition_id": "career-2020-2022", - "dimension_code": "career", - "candidate_scores": {"05:30": 0.0, "05:31": 1.0, "05:32": 1.0, "05:33": 0.0}, - "information_gain": 0.5, - }], - }) - assert result["effective_answer_count"] == 1 - assert result["winning_segment"] == { - "start_time": "05:31", "end_time": "05:32", "representative_time": "05:31", "width_minutes": 2, - } - assert result["can_apply"] is False - -def test_unknown_and_unmatched_are_never_choice_evidence(): - with pytest.raises(ValueError, match="partition evidence"): - dynamic_rectification.score_choice_evidence({ - **score_request(), - "choice_evidence": [{"kind": "unknown"}], - }) - -def test_high_confidence_requires_versioned_hard_gates(): - result = dynamic_rectification.adjudicate_choice_rows( - decisive_rows(), effective_answer_count=4, dimension_count=3, missing_layers=[] - ) - assert result["confidence"] == "high" - assert result["can_apply"] is True - assert result["winning_segment"]["width_minutes"] <= 5 - assert result["margin_percent"] >= 20 -``` - -- [ ] **Step 5: Run scoring tests and verify RED** - -Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py -k 'primary_choice or unknown or high_confidence'` - -Expected: FAIL because choice scoring functions are absent. - -- [ ] **Step 6: Add versioned scoring gates** - -Set `ALGORITHM_VERSION = "birth-time-choice-scoring-v2"`. Sum only server-resolved primary evidence. Keep `answered_count` separate from `effective_answer_count`; the Python scorer receives only effective evidence. Return existing candidate-result compatibility fields, with `event_count = effective_answer_count`, `domain_count = dimension_count`, and an empty public `evidence` array because private choice evidence remains in the service-only table. Also return: - -```python -{ - "evidence_mode": "dynamic_choice", - "effective_answer_count": effective_answer_count, - "dimension_count": dimension_count, - "algorithm_version": ALGORITHM_VERSION, -} -``` - -High confidence requires one winning segment, at least 4 effective answers across 3 dimensions, width at most 5 minutes, margin at least 20%, and no missing mandatory layers. Medium requires one segment, at least 3 effective answers across 2 dimensions, width at most 15 minutes, and margin at least 10%. Every other result is low and `can_apply` is false. - -- [ ] **Step 7: Add strict API validation and endpoints** - -For opportunities accept only birth date, a persisted ISO `as_of_date`, start/end time, required location, an optional server-owned `candidate_model`, existing choice evidence summary, dismissed opportunity IDs, and fingerprint arrays. For scoring accept only birth/location/range and server-resolved `choice_evidence`. Reject candidate models whose version, bound location/range, candidate times, or numeric activation shape do not match the request; also reject candidate times outside the submitted range, duplicate question IDs, more than 10 evidence rows, non-finite scores, unsupported dimensions, and any client-style `option_id` field. Accept opaque trimmed nonempty server-issued question IDs rather than UUID-only IDs. Window generation uses `as_of_date`, never the Python process clock, so an existing case remains reproducible across days. - -Both dynamic Python endpoints are server-to-server only. Require a constant-time checked bearer token from `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN`, fail closed when it is absent, and remove the dynamic endpoints from any browser-runnable technique-example dispatch. The authenticated TypeScript adapter in Task 3 is the only application caller; a browser must not be able to submit `candidate_model`, `partition_id`, or `candidate_scores` directly. - -- [ ] **Step 8: Run Python suites and commit** - -Run: `.venv/bin/python -m pytest -q tests/test_dynamic_rectification.py tests/test_active_rectification_api.py tests/test_active_rectification_questions.py tests/test_active_rectification_events.py` - -Expected: all selected tests pass. - -```bash -git add scripts/dynamic_rectification.py scripts/jyotish_api_server.py tests/test_dynamic_rectification.py tests/test_active_rectification_api.py -git commit -m "feat: score dynamic birth time choices" -``` - ---- - -### Task 3: TypeScript Engine Adapter and Trust Boundary - -**Files:** -- Modify: `frontend/src/lib/birth-time-journey-service.ts:1-120` -- Modify: `frontend/src/lib/birth-time-journey-engine.ts` -- Modify: `frontend/src/lib/birth-time-journey-adapters.ts` -- Create: `frontend/src/lib/birth-time-journey-dynamic-adapters.ts` -- Modify: `frontend/src/lib/birth-time-journey-engine-model.ts` -- Modify: `frontend/src/lib/birth-time-evidence.ts:86-150` -- Test: `frontend/tests/birth-time-journey-engine.test.ts` -- Test: `frontend/tests/birth-time-journey-adapters.test.ts` -- Test: `frontend/tests/birth-time-journey-dynamic-adapters.test.ts` -- Test support: `frontend/tests/birth-time-journey-memory-store.ts` - -**Interfaces:** -- Adds `buildDifferencePacket(input: DifferencePacketInput): Promise`. -- Adds `scoreChoices(input: DynamicChoiceScoreInput): Promise`. -- Preserves `scan`, `score`, and `scoreEvents` for legacy protocol cases. - -- [ ] **Step 1: Write failing adapter tests** - -```ts -test("difference packets keep candidate scores on the server-only internal shape", () => { - const build = parseCandidateDifferenceBuild(apiPacket); - assert.equal(build.scoringPartitions["career-window"][0].candidateScores["05:31"], 1); - assert.equal(build.packet.opportunities[0].estimatedInformationGain, 0.5); - assert.deepEqual(build.candidateModel, apiPacket.candidate_model); -}); - -test("choice score parser rejects model-controlled confidence fields", () => { - assert.throws(() => parseDynamicChoiceScoring({ - ...apiScore, - confidence: "high", - effective_answer_count: 1, - can_apply: true, - })); -}); - -test("choice scores adapt into the existing guarded candidate shape", () => { - const parsed = parseDynamicChoiceScoring(apiScore); - assert.equal(parsed.candidate.eventCount, parsed.effectiveAnswerCount); - assert.equal(parsed.candidate.domainCount, parsed.dimensionCount); - assert.deepEqual(parsed.candidate.evidence, []); - assert.equal(parsed.candidate.algorithmVersion, "birth-time-choice-scoring-v2"); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts` - -Expected: FAIL because both parsers and engine methods are missing. - -- [ ] **Step 3: Add exact engine inputs** - -```ts -export type DifferencePacketInput = { - readonly caseId: string; - readonly asOfDate: string; - readonly birthDate: string; - readonly startTime: string; - readonly endTime: string; - readonly lat: number; - readonly lon: number; - readonly tz: number; - readonly evidence: readonly ServerChoiceEvidence[]; - readonly dismissedOpportunityIds: readonly string[]; - readonly questionFingerprints: readonly string[]; - readonly partitionFingerprints: readonly string[]; - readonly recentRanges: readonly TimeRange[]; - readonly candidateModel: Readonly> | null; -}; - -export type DynamicChoiceScoreInput = Pick; -``` - -Extend `BirthTimeJourneyEngine` with the two methods. Do not add partition data to any client response schema. - -Keep the primary `BirthTimeJourneyEngine` contract fully capable: both dynamic methods are required. Use an explicit legacy-only `Pick`/interface for old services and test doubles that intentionally need only `scan`, `score`, and `scoreEvents`; do not weaken the primary methods to optional. - -Raise the compatibility `candidateResultSchema.eventCount` maximum from 6 to 10 and change its high-gate message from “events” to “effective evidence items.” The dated-event request schema remains capped at 6, so legacy API behavior does not broaden; the shared candidate result can now represent the v2 safety cap. - -- [ ] **Step 4: Post to the new Python endpoints** - -`buildDifferencePacket()` posts snake-case payloads to `/api/dynamic_rectification_opportunities` and separates the response into `{ packet, candidateModel, scoringPartitions }`. Only `packet` may enter the Agent prompt; `candidateModel` and `scoringPartitions` stay server-only. `bindDynamicQuestion()` copies the selected partition's score vector into the private persisted question, and the model cannot supply or alter that vector. `scoreChoices()` posts to `/api/dynamic_rectification_score`. Both use the existing 45-second abort timeout and strict adapter parsing. - -For both dynamic calls, require `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` in the server environment and send it as a bearer token. Never expose that token through a client module or response. Legacy engine calls remain unchanged and unauthenticated. - -`parseDynamicChoiceScoring()` must require `event_count === effective_answer_count`, `domain_count === dimension_count`, `evidence_mode === "dynamic_choice"`, an empty public evidence array, and the v2 algorithm version before constructing `DynamicChoiceScoringResult`. This prevents a malformed engine payload from satisfying the high-confidence gate with inconsistent counts. - -Place all v2 response schemas and mappings in `birth-time-journey-dynamic-adapters.ts`; keep legacy parsing behavior byte-compatible in `birth-time-journey-adapters.ts`. Every nested dynamic object, including `winning_segment`, is strict. Keep each production and test module within 250 pure LOC, add duplicate opportunity/partition attack tests, and assert mapped fields against independent input fixtures rather than against each other. - -Test authentication through an executable fake fetch/wire seam for both dynamic endpoints: exact URL, bearer header, request body, timeout signal, and missing-token fail-before-fetch. Also prove legacy calls omit the dynamic Authorization header. The HTTP helper accepts one typed request/options object rather than four primitive parameters. - -Wire assertions use independent literal request expectations, not the production serializer as the expected value. Inject the timeout-signal factory in tests and assert it receives the literal `45_000`; do not infer the timeout from a sibling exported constant. If a pre-existing test-support module exceeds the limit, extract the memory journey store into `birth-time-journey-memory-store.ts` instead of compressing formatting to pass the LOC check. - -- [ ] **Step 5: Verify endpoint payload ownership** - -Add a source-level test asserting that `candidate_scores` appears only in server modules and never in `birth-time-journey-client.ts`, `birth-time-journey-request.ts`, or a component/hook. - -- [ ] **Step 6: Run focused tests and commit** - -Run: `cd frontend && node --test tests/birth-time-journey-engine.test.ts tests/birth-time-journey-adapters.test.ts` - -Expected: all selected tests pass. - -```bash -git add frontend/src/lib/birth-time-journey-service.ts frontend/src/lib/birth-time-journey-engine.ts frontend/src/lib/birth-time-journey-adapters.ts frontend/src/lib/birth-time-journey-engine-model.ts frontend/src/lib/birth-time-evidence.ts frontend/tests/birth-time-journey-engine.test.ts frontend/tests/birth-time-journey-adapters.test.ts -git commit -m "feat: connect dynamic rectification engine" -``` - ---- - -### Task 4: Constrained Agent Question Generation and Fallback - -**Files:** -- Create: `frontend/src/lib/birth-time-dynamic-question-validator.ts` -- Modify: `frontend/src/lib/birth-time-guide-agent.ts` -- Modify: `frontend/src/lib/birth-time-guide-service.ts` -- Modify: `frontend/src/mastra/index.ts:179-220` -- Test: `frontend/tests/birth-time-guide-agent.test.ts` -- Test: `frontend/tests/birth-time-guide-route.test.ts` - -**Interfaces:** -- Produces `generateDynamicQuestionPrompt(packet, note)` and `parseDynamicQuestionOutput(value, packet)`. -- Produces `bindDynamicQuestion(output, build, ids): PersistedDynamicChoiceQuestion`; `build.packet` supplies model-safe IDs/copy and `build.scoringPartitions` supplies the private score vector. -- Model output is either `{ kind: "question", opportunityId, prompt, options }` or `{ kind: "no_useful_question" }`. - -- [ ] **Step 1: Replace variant tests with failing dynamic-output tests** - -```ts -test("agent output may only reference one server opportunity and its partitions", () => { - const parsed = parseDynamicQuestionOutput({ - kind: "question", - opportunityId: "career-window", - prompt: "哪一个时间段更接近一次明显的工作变化?", - options: [ - { partitionId: "window-a", label: "2018—2020 年" }, - { partitionId: "window-b", label: "2021—2023 年" }, - ], - }, packet); - assert.equal(parsed.kind, "question"); - assert.throws(() => parseDynamicQuestionOutput({ - ...parsed, - options: [{ partitionId: "invented", label: "某个时间" }], - }, packet), BirthTimeGuideOutputError); -}); - -test("server adds special options and keeps partitions private", () => { - const internal = bindDynamicQuestion(validOutput, differenceBuild, deterministicIds); - const publicQuestion = toPublicDynamicChoiceQuestion(internal); - assert.deepEqual(publicQuestion.options.slice(-2).map((item) => item.label), ["不确定 / 不记得", "都不符合"]); - assert.equal(publicQuestion.options.some((item) => "partitionId" in item), false); -}); -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `cd frontend && node --test tests/birth-time-guide-agent.test.ts` - -Expected: FAIL because dynamic generation functions do not exist. - -- [ ] **Step 3: Define the model prompt boundary** - -Send only opportunity ID, dimension code, neutral context, partition ID, descriptor, fallback label, prior public question summaries, and the optional unmatched note. Do not send candidate times, candidate scores, partition memberships, confidence thresholds, or support directions. - -The Mastra instruction must require valid JSON only, one question, 2–4 options, neutral Simplified Chinese, no birth-minute claim, no methodology exposure, and exact server IDs. It must state that `no_useful_question` is advisory and the server makes the stop decision. - -- [ ] **Step 4: Bind, fingerprint, and validate server-side** - -`bindDynamicQuestion()` must: - -1. verify the opportunity exists; -2. verify each partition belongs to it and appears once; -3. require 2–4 primary labels; -4. reject prompts over 120 characters and labels over 80; -5. reject time-of-birth strings matching `HH:MM`, confidence language, candidate-support language, and control claims; -6. create UUIDs server-side for question/options; -7. add the two special options with null partitions; -8. hash normalized public semantics for `questionFingerprint`; -9. reject existing question or partition fingerprints. - -- [ ] **Step 5: Add one retry and deterministic fallback tests** - -```ts -test("invalid model output retries once then persists the top opportunity fallback", async () => { - const calls: string[] = []; - const result = await serviceWithGenerator(async () => { - calls.push("generate"); - return { text: "{}" }; - }).generateQuestion("owner-1", generationCommand); - assert.equal(calls.length, 2); - assert.equal(result.nextAction.kind, "ask_dynamic_choice"); - assert.equal(result.nextAction.question.prompt, packet.opportunities[0].fallbackPrompt); - assert.equal(result.nextAction.question.options.length, packet.opportunities[0].partitions.length + 2); -}); - -test("no opportunity ends safely instead of regenerating the first question", async () => { - const result = await serviceWithPacket({ ...packet, opportunities: [] }) - .generateQuestion("owner-1", generationCommand); - assert.equal(result.nextAction.kind, "present_low_result"); -}); - -test("model no_useful_question cannot stop while the engine has an opportunity", async () => { - const result = await serviceWithGenerator(async () => ({ - text: JSON.stringify({ kind: "no_useful_question" }), - })).generateQuestion("owner-1", generationCommand); - assert.equal(result.nextAction.kind, "ask_dynamic_choice"); - assert.equal(result.nextAction.question.prompt, packet.opportunities[0].fallbackPrompt); -}); -``` - -- [ ] **Step 6: Run focused tests and commit** - -Run: `cd frontend && node --test tests/birth-time-guide-agent.test.ts tests/birth-time-guide-route.test.ts` - -Expected: all selected tests pass. - -```bash -git add frontend/src/lib/birth-time-dynamic-question-validator.ts frontend/src/lib/birth-time-guide-agent.ts frontend/src/lib/birth-time-guide-service.ts frontend/src/mastra/index.ts frontend/tests/birth-time-guide-agent.test.ts frontend/tests/birth-time-guide-route.test.ts -git commit -m "feat: generate constrained dynamic choice questions" -``` - -#### Task 4 review amendment (mandatory before Task 5) - -Independent review of `437d50f..1ffc09e` blocked Task 4. Complete and independently re-review -these corrections before persistence work begins: - -- Localize engine-owned `neutral_context`, `fallback_prompt`, and fallback labels with a - deterministic Simplified-Chinese dimension map. Add a real Task 2 Python-shaped - adapter-to-service regression proving two invalid Agent responses still persist the - highest-gain fallback. -- Treat `unmatchedNote` as untrusted evidence: discard or redact birth-time, scoring, - confidence, support, control, and instruction-like content before the model boundary; label - the remaining text as untrusted quoted data. Require generated public copy to be grounded in - the selected opportunity's localized context so valid IDs cannot authorize unrelated copy. -- Separate recoverable Agent-output/repetition failures from server binding, private scoring, - UUID, and persisted-schema failures. Validate bindings before allocating IDs, catch only - recoverable variants, and never translate a server fault into `present_low_result`. -- Enforce byte-exact model IDs and close the reviewed confidence/support/control wording gaps. - Keep `bindDynamicQuestion(output, build, ids)` as the agent-facing API and use a separate - fallback binder for server-owned source selection. -- Split the dynamic tests and shared fixtures so every changed TypeScript test module is at or - below 250 pure lines. Add distinct-input semantic-normalization coverage and replace - sanitized-only integration fixtures with the real engine shape. -- Correct `.superpowers/sdd/task-4-report.md` and reference durable RED/GREEN/gate artifacts. - -This amendment expands Task 4 ownership to -`scripts/dynamic_rectification_opportunities.py`, its focused Python test, and focused -dynamic-question test/fixture modules. Prior public-question summaries are deferred to Task 6, -where persisted question history becomes available; Task 4 continues to enforce exact server -fingerprints without fabricating summaries from hashes. - -#### Task 4 second review amendment (finite rendering contract) - -The corrected range `437d50f..797cb65` is still blocked because free-form notes and model-authored -labels remain bypassable. The final Task 4 boundary is therefore: - -- Raw `unmatchedNote` never crosses the Agent boundary. Task 4 omits it rather than attempting - semantic instruction detection with keyword filters. -- Agent output is selection-only: `{ kind: "question", opportunityId }` or - `{ kind: "no_useful_question" }`. The server renders the selected engine opportunity's - prompt and primary labels; model-authored prompt/label copy is not accepted. -- The model dynamically chooses the next information opportunity, while the deterministic - engine owns partitions/answer semantics and the server owns a finite public rendering - grammar. This is the approved hybrid design, not a fixed-round questionnaire. -- `bindDynamicQuestion(selection, build, ids)` validates unique normalized server labels and - binds them to private partitions. Duplicate/malformed server copy is a binding fault that - propagates; it cannot be retried as model output or converted to low confidence. -- Python range labels select year/month/day precision as needed so distinct same-year windows - remain visibly distinct. Fallback explicitly chooses maximum information gain with stable ID - tie-breaking rather than trusting packet order. -- Required regressions cover the tea/water note bypass, inability for the model to author or - duplicate labels, unsorted multi-opportunity fallback, valid Agent selection with correct - private bindings, same-year unique labels, and the real Python public-copy seam. Superseded - `CLEAR` evidence and the Task 4 report must be corrected with fresh artifact paths. - ---- - -### Task 5: Durable v2 Persistence and Legacy Isolation - -**Files:** -- Create: `frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql` -- Modify: `frontend/src/lib/birth-time-journey-turn-persistence.ts` -- Modify: `frontend/src/lib/birth-time-journey-store.ts` -- Modify: `frontend/src/lib/birth-time-journey-service.ts` -- Modify: `tests/test_birth_time_journey_contract.py` -- Test: `frontend/tests/birth-time-dynamic-persistence.test.ts` - -**Interfaces:** -- Persists `journey_protocol` on the existing public case row. -- Persists the candidate model, internal current question, choice answers, server choice evidence, dynamic control state, and optional Agent context in `birth_time_rectification_dynamic_state`, which authenticated clients cannot select. -- Adds `saveDynamicTurn(value, expectedVersion, actionId)` and `upgradeLegacyActiveCase(value)`. -- Existing terminal cases remain terminal and are never upgraded into a question state. - -- [ ] **Step 1: Write failing migration contract tests** - -```python -def test_dynamic_choice_migration_keeps_private_mapping_and_agent_context_server_side(): - sql = DYNAMIC_CHOICE_MIGRATION.read_text() - assert "journey_protocol text not null default 'legacy-guided-v1'" in sql - assert "create table if not exists public.birth_time_rectification_dynamic_state" in sql - assert "candidate_model jsonb" in sql - assert "current_choice_question jsonb" in sql - assert "choice_answers jsonb not null default '[]'::jsonb" in sql - assert "choice_evidence jsonb not null default '[]'::jsonb" in sql - assert "dynamic_control jsonb" in sql - assert "agent_context jsonb not null default '[]'::jsonb" in sql - assert "revoke all on table public.birth_time_rectification_dynamic_state from anon, authenticated" in sql - assert "grant all on table public.birth_time_rectification_dynamic_state to service_role" in sql - assert "save_birth_time_dynamic_turn" in sql - assert "complete_birth_time_dynamic_scoring_job" in sql - assert "fail_birth_time_dynamic_scoring_job" in sql -``` - -- [ ] **Step 2: Run and verify RED** - -Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py -k dynamic_choice` - -Expected: FAIL because the migration is absent. - -- [ ] **Step 3: Add a private dynamic-state table and transactional RPC** - -Add only `journey_protocol` to `birth_time_rectification_cases`, allowing `legacy-guided-v1` or `dynamic-choice-v2`. Create `birth_time_rectification_dynamic_state` with `case_id` primary/foreign key, `user_id`, `candidate_model`, the other five private JSON fields, and timestamps. Add JSON type checks, cap the audit-only `choice_answers` array at 50 rows, cap effective `choice_evidence` at 10 rows, and cap Agent context at 10 notes of at most 240 characters. Enable RLS, revoke every privilege from `anon` and `authenticated`, and grant all only to `service_role`. - -Create `save_birth_time_dynamic_turn(p_user_id, p_case_id, p_expected_version, p_action_id, p_public_turn_state, p_snapshot, p_candidate_result, p_private_state)`. The function must be `security definer`, set `search_path = ''`, require the matching owner and `dynamic-choice-v2`, perform the optimistic version/action-receipt update, and upsert the private row in the same database transaction. Return the new version; return the existing version for a replayed action; raise `stale_birth_time_dynamic_turn` otherwise. Revoke function execution from `public`, `anon`, and `authenticated`; grant it only to `service_role`. - -Create matching service-role-only `complete_birth_time_dynamic_scoring_job(...)` and `fail_birth_time_dynamic_scoring_job(...)` RPCs. Each verifies the owner, case, job ID, expected turn version, evidence fingerprint, algorithm version, and current job state before atomically updating the job, public turn/result, and private dynamic state. A replay returns the already completed/failed turn; a mismatch raises a stale-job exception. - -- [ ] **Step 4: Write failing store tests** - -```ts -test("v2 load restores the exact internal question after refresh", async () => { - const loaded = await loadStoredRectificationCase(fakeSupabase(v2CaseRow, v2PrivateRow), "owner", caseId); - assert.deepEqual(loaded?.currentChoiceQuestion, persistedQuestion); - assert.deepEqual(loaded?.candidateModel, persistedCandidateModel); - assert.deepEqual(loaded?.dynamicControl.questionFingerprints, [persistedQuestion.questionFingerprint]); -}); - -test("save uses optimistic version and action receipt once", async () => { - const first = await store.saveDynamicTurn(updated, 7, actionId); - const replay = await store.saveDynamicTurn(updated, 7, actionId); - assert.equal(first.turnVersion, 8); - assert.equal(replay.turnVersion, 8); - assert.equal(replay.processedActionIds.filter((value) => value === actionId).length, 1); -}); -``` - -- [ ] **Step 5: Extend stored case parsing and persistence** - -Discriminate by `journey_protocol`. `saveAssessment()` explicitly creates a `dynamic-choice-v2` case, initializes `asOfDate`, and inserts its empty private state before returning the case ID. For v2 resume, load the owner-scoped public case row and the service-role-only private row, then parse private JSON with Task 1 schemas; a missing private row is a store error, not an excuse to regenerate from scratch. `saveDynamicTurn()` calls the transactional RPC and never writes `active_birth_time`. Only `toPublicDynamicChoiceQuestion(currentChoiceQuestion)` is stored in public `turn_state` and projected into `nextAction`; candidate scores, partition IDs, and Agent notes never enter the case row. - -- [ ] **Step 6: Define legacy upgrade rules** - -`upgradeLegacyActiveCase()` is allowed only when the old case is nonterminal. It preserves `answers`, `life_events`, questionnaire, candidate result, reported range, and audit timestamps; sets protocol v2; initializes dynamic counters from confirmed legacy evidence; excludes legacy question fingerprints; and sets `generate_dynamic_question`. Old `present_low_result`, `present_medium_result`, confirmation, and ready states return unchanged. - -- [ ] **Step 7: Run persistence tests and commit** - -Run: `.venv/bin/python -m pytest -q tests/test_birth_time_journey_contract.py && cd frontend && node --test tests/birth-time-dynamic-persistence.test.ts tests/birth-time-journey-turn-persistence.test.mjs` - -Expected: all selected tests pass. - -```bash -git add frontend/supabase/migrations/20260718090000_dynamic_choice_birth_time_rectification.sql frontend/src/lib/birth-time-journey-turn-persistence.ts frontend/src/lib/birth-time-journey-store.ts frontend/src/lib/birth-time-journey-service.ts tests/test_birth_time_journey_contract.py frontend/tests/birth-time-dynamic-persistence.test.ts -git commit -m "feat: persist dynamic rectification turns" -``` - -#### Task 5 review amendment - -Review expands Task 5 ownership to the following correctness and maintainability fixes before -Task 6: - -- Split the migration into ordered schema/turn and scoring-job RPC migrations, each at or below - 250 pure lines. Deduplicate private-state persistence through one service-role-only SQL helper. -- Create public v2 case, required private state, and exact profile link atomically through a - service-role `create_birth_time_dynamic_case` RPC; never use separate inserts. -- Apply protocol isolation to every legacy guided mutation and scoring-poll path, not only - question/evidence actions. -- Add ordered protocol-guard migrations for existing legacy scoring/candidate RPCs: public - signatures stay stable, internal bodies are not executable by API roles, and service-role - wrappers owner-lock and verify `legacy-guided-v1` atomically. Direct legacy PostgREST writes - include the same protocol predicate. -- Parse external rows into a strict `journeyProtocol`-discriminated stored-case union; normalize - absent old protocol values to legacy at the loader boundary. -- Make memory replay return the stored advanced state. Map supported unknown time ranges to the - full day while continuing to reject malformed mixed-null ranges. -- Split contract tests below 250 lines and replace deletion-only/source-mirroring claims with - executable store behavior. If local Postgres execution is unavailable, preserve evidence of - the environment limitation and make no live-database claim. -- Expose typed persistence wrappers for dynamic scoring completion/failure RPCs and cover exact - payloads, replay/version results, and stale/error propagation with executable fakes. Task 6 - continues to own stop-policy and scoring orchestration. - ---- - -### Task 6: Journey Actions, Scoring Jobs, and Anti-Loop Transitions - -#### Task 6 persistence amendment - -Task 5 intentionally exposed only typed dynamic scoring completion/failure wrappers. Its -legacy scoring protocol guards make the existing public create/claim RPCs unavailable to -`dynamic-choice-v2`, so Task 6 must also close the v2 job lifecycle rather than bypassing the -private-state boundary or leaving browser polling unable to complete. - -- Add one ordered migration at or below 250 pure lines for - `create_birth_time_dynamic_scoring_job(...)` and - `claim_birth_time_dynamic_scoring_job(...)`. -- Add a later ordered replacement for `save_birth_time_dynamic_turn(...)` so a processed - action succeeds only when the locked private `lastActionReceipt` exactly matches the proposed - canonical receipt and expected next version. Apply the same exact-receipt rule to dynamic - scoring-job creation; TypeScript success and error reloads must independently verify it. -- Creation owner-locks a v2 case, validates expected version/action/question/job/fingerprint/ - algorithm, atomically persists the advanced public turn, private dynamic state, canonical - action receipt, and one pending job, and replays only the identical completed action. -- Claim owner-locks the v2 case, validates job identity, fingerprint, algorithm, current - `score_pending`/`retry_scoring` action, and the processing lease. Completed replay is allowed - only when the stored candidate result and dynamic terminal/continuation action agree. -- Add typed production store methods and executable fake/store tests. Do not call the - legacy-guarded public wrappers or write the service-only private table from orchestration. -- If live PostgreSQL is unavailable, record that limitation explicitly and retain executable - TypeScript RPC-fake evidence plus static SQL contract/syntax checks without claiming a live - database pass. - -**Files:** -- Create: `frontend/supabase/migrations/20260718094000_dynamic_choice_scoring_job_lifecycle.sql` -- Create: `frontend/supabase/migrations/20260718095000_dynamic_choice_exact_action_receipts.sql` -- Create: `frontend/src/lib/birth-time-dynamic-transitions.ts` -- Create: `frontend/src/lib/birth-time-dynamic-actions.ts` -- Create: `frontend/src/lib/birth-time-dynamic-scoring-service.ts` -- Modify: `frontend/src/lib/birth-time-journey-service.ts` -- Modify: `frontend/src/lib/birth-time-scoring-job.ts` -- Modify: `frontend/src/lib/birth-time-scoring-job-store.ts` -- Test: `frontend/tests/birth-time-dynamic-actions.test.ts` -- Test: `frontend/tests/birth-time-dynamic-scoring.test.ts` -- Test: `frontend/tests/birth-time-dynamic-terminal.test.ts` -- Test: `tests/test_birth_time_dynamic_scoring_job_contract.py` - -**Interfaces:** -- Produces `answerDynamicChoice`, `submitUnmatchedContext`, `generateDynamicQuestion`, `pauseDynamic`, `resumeDynamic`, and `finishDynamic` service actions. -- Primary choices resolve a stored partition and create one idempotent `birth-time-choice-scoring-v2` job. -- Unknown and unmatched answers never create `ServerChoiceEvidence`. - -- [ ] **Step 1: Write failing primary-answer tests** - -```ts -test("a primary click resolves its private partition and enters score_pending", async () => { - const result = await flow.answerDynamicChoice("owner", { - caseId, actionId, turnVersion: 4, questionId, optionId: primaryOptionId, - }); - assert.equal(result.nextAction.kind, "score_pending"); - assert.equal(flow.saved.choiceAnswers.length, 1); - assert.equal(flow.saved.choiceEvidence[0].partitionId, "window-a"); - assert.equal(flow.saved.dynamicControl.effectiveAnswerCount, 1); -}); - -test("a forged or stale option cannot affect evidence", async () => { - await assert.rejects(() => flow.answerDynamicChoice("owner", { - caseId, actionId, turnVersion: 3, questionId, optionId: forgedOptionId, - }), StaleJourneyTurnError); - assert.deepEqual(flow.saved.choiceEvidence, []); -}); -``` - -- [ ] **Step 2: Run action tests and verify RED** - -Run: `cd frontend && node --test tests/birth-time-dynamic-actions.test.ts` - -Expected: FAIL because dynamic actions do not exist. - -- [ ] **Step 3: Implement special-choice transitions** - -- Primary: persist answer and private evidence, increment both counts, clear current question, create score job. -- Unknown: persist a non-effective answer, increment only `answeredCount`, dismiss the opportunity/fingerprints, clear current question, enter `generate_dynamic_question`. -- Unmatched: persist a non-effective answer, increment only `answeredCount`, retain the question, enter `clarify_unmatched_answer`. -- Unmatched context: validate at most 240 characters, persist separate Agent context, dismiss the old opportunity/fingerprints, clear the question, enter `generate_dynamic_question` without scoring. -- Finish: preserve current result/range and enter a terminal low or medium result. - -- [ ] **Step 4: Write failing score-completion tests** - -```ts -test("score completion continues only when the stop policy allows it", async () => { - const result = await scoring.complete(lowChangedScore, packetWithUsefulOpportunity); - assert.equal(result.nextAction.kind, "generate_dynamic_question"); -}); - -test("the second plateau is terminal and resume stays terminal", async () => { - const terminal = await scoring.complete(mediumUnchangedScore, packetWithUsefulOpportunity); - assert.equal(terminal.nextAction.kind, "present_medium_result"); - const resumed = await flow.resumeDynamic("owner", caseId); - assert.deepEqual(resumed.nextAction, terminal.nextAction); -}); - -test("high confidence still requires explicit confirmation", async () => { - const result = await scoring.complete(highScore, packetWithUsefulOpportunity); - assert.equal(result.nextAction.kind, "request_candidate_confirmation"); - assert.equal(result.snapshot.activeTime, null); - assert.equal(result.permissions.canConfirmCandidate, true); -}); -``` - -- [ ] **Step 5: Run scoring tests and verify RED** - -Run: `cd frontend && node --test tests/birth-time-dynamic-scoring.test.ts tests/birth-time-dynamic-terminal.test.ts` - -Expected: FAIL because v2 completion and terminal guards are absent. - -- [ ] **Step 6: Add scoring claim/completion flow** - -Fingerprint canonical server choice evidence, not public labels or Agent notes. Claim jobs by case, evidence fingerprint, and algorithm version. Validate returned effective count, dimension count, algorithm version, candidate range, and confidence gates before persisting. Apply `decideDynamicStop()` in the same saved turn as the candidate result; never expose an intermediate low result that `resume()` could reinterpret as a new cycle. - -- [ ] **Step 7: Make terminal transitions one-way** - -Every answer, generation, reframe, retry, and scoring action must reject a terminal `nextAction`. `resumeDynamic()` returns the stored terminal state byte-for-byte. `pauseDynamic()` stores the exact non-paused action in `dynamicControl.pausedAction`; resume restores only that action and clears the saved pause action. - -- [ ] **Step 8: Run focused tests and commit** - -Run: `cd frontend && node --test tests/birth-time-dynamic-actions.test.ts tests/birth-time-dynamic-scoring.test.ts tests/birth-time-dynamic-terminal.test.ts tests/birth-time-scoring-job.test.ts` - -Expected: all selected tests pass. - -```bash -git add frontend/src/lib/birth-time-dynamic-transitions.ts frontend/src/lib/birth-time-dynamic-actions.ts frontend/src/lib/birth-time-dynamic-scoring-service.ts frontend/src/lib/birth-time-journey-service.ts frontend/src/lib/birth-time-scoring-job.ts frontend/src/lib/birth-time-scoring-job-store.ts frontend/tests/birth-time-dynamic-actions.test.ts frontend/tests/birth-time-dynamic-scoring.test.ts frontend/tests/birth-time-dynamic-terminal.test.ts -git commit -m "feat: orchestrate dynamic rectification turns" -``` - ---- - -### Task 7: Authenticated API, Client Commands, and Automatic Browser Coordination - -**Files:** -- Modify: `frontend/src/lib/birth-time-journey-request.ts` -- Modify: `frontend/src/lib/birth-time-journey-response-schema.ts` -- Modify: `frontend/src/lib/birth-time-journey-client.ts` -- Modify: `frontend/src/app/api/birth-time-journey/route.ts` -- Modify: `frontend/src/app/api/birth-time-guide/route.ts` -- Modify: `frontend/src/hooks/use-birth-time-guided-journey.ts` -- Test: `frontend/tests/birth-time-dynamic-api.test.ts` -- Modify: `frontend/tests/birth-time-guide-client.test.ts` -- Modify: `frontend/tests/birth-time-guided-polling.test.ts` - -**Interfaces:** -- Journey command: `{ type: "answer_dynamic_choice", caseId, actionId, turnVersion, questionId, optionId }`. -- Guide commands: `generate_dynamic_question` and `reframe_unmatched`. -- Controller exposes `selectOption(optionId)`, `submitUnmatchedContext(note)`, `finish()`, `pause()`, and existing candidate actions. - -- [ ] **Step 1: Write failing request-boundary tests** - -```ts -test("choice commands accept only public ids", () => { - const valid = { type: "answer_dynamic_choice", caseId, actionId, turnVersion: 4, questionId, optionId }; - assert.equal(birthTimeJourneyRequestSchema.safeParse(valid).success, true); - for (const field of ["partitionId", "candidateScores", "confidence", "time"] as const) { - assert.equal(birthTimeJourneyRequestSchema.safeParse({ ...valid, [field]: "forged" }).success, false); - } -}); - -test("unmatched context is optional, trimmed, and bounded", () => { - assert.equal(birthTimeGuideRequestSchema.safeParse({ - type: "reframe_unmatched", caseId, actionId, turnVersion: 5, questionId, note: " 更像是 2017 年 ", - }).success, true); - assert.equal(birthTimeGuideRequestSchema.safeParse({ - type: "reframe_unmatched", caseId, actionId, turnVersion: 5, questionId, note: "字".repeat(241), - }).success, false); -}); -``` - -- [ ] **Step 2: Run API tests and verify RED** - -Run: `cd frontend && node --test tests/birth-time-dynamic-api.test.ts tests/birth-time-guide-client.test.ts` - -Expected: FAIL because v2 commands are absent. - -- [ ] **Step 3: Add strict route dispatch** - -Authenticate before body parsing. Route each v2 command to only its scoped service method. Map stale/terminal/forged actions to 409, missing cases to 404, invalid model output to the deterministic fallback path, and engine/store outages to 503 while preserving the current question. Record metrics after persisted transitions only. - -- [ ] **Step 4: Add automatic generation and scoring coordination** - -In the hook: - -- on `generate_dynamic_question`, call the guide route once per `caseId:turnVersion` identity; -- on `score_pending`, poll the existing idempotent job identity; -- on network failure, keep the same action and show retry; do not optimistically create another question; -- on `ask_dynamic_choice`, render the persisted public question directly, without a second render-question request; -- on primary click, disable all options until the mutation resolves; -- on terminal result, stop all generation and polling effects. - -- [ ] **Step 5: Add race/replay tests** - -```ts -test("duplicate option clicks publish one advanced turn", async () => { - const requests = coordinateDuplicateClicks(); - await Promise.all([requests.select(primaryOptionId), requests.select(primaryOptionId)]); - assert.equal(requests.sent.length, 1); - assert.equal(requests.published.at(-1)?.nextAction.kind, "score_pending"); -}); - -test("a stale generated question cannot replace a newer turn", async () => { - const result = await resolveGenerationAfterTurnAdvanced(); - assert.equal(result.current.turnVersion, 8); - assert.notEqual(result.current.nextAction.kind, "ask_dynamic_choice"); -}); -``` - -- [ ] **Step 6: Run focused tests and commit** - -Run: `cd frontend && node --test tests/birth-time-dynamic-api.test.ts tests/birth-time-guide-client.test.ts tests/birth-time-guided-polling.test.ts tests/birth-time-guided-review-fixes.test.ts` - -Expected: all selected tests pass. - -```bash -git add frontend/src/lib/birth-time-journey-request.ts frontend/src/lib/birth-time-journey-response-schema.ts frontend/src/lib/birth-time-journey-client.ts frontend/src/app/api/birth-time-journey/route.ts frontend/src/app/api/birth-time-guide/route.ts frontend/src/hooks/use-birth-time-guided-journey.ts frontend/tests/birth-time-dynamic-api.test.ts frontend/tests/birth-time-guide-client.test.ts frontend/tests/birth-time-guided-polling.test.ts -git commit -m "feat: expose dynamic rectification actions" -``` - ---- - -### Task 8: Click-First Question UI and Simplified Progress - -**Files:** -- Create: `frontend/src/components/birth-time-choice-question.tsx` -- Modify: `frontend/src/components/birth-time-rectification.tsx` -- Modify: `frontend/src/components/birth-time-candidate-result.tsx` -- Modify: `frontend/src/app/globals.css:360-415,650-670` -- Modify: `frontend/src/hooks/use-birth-time-guided-journey.ts` -- Test: `frontend/tests/birth-time-choice-question.test.ts` -- Modify: `frontend/tests/birth-time-guide-flow.test.ts` -- Modify: `frontend/tests/birth-time-rectification-contract.test.ts` - -**Interfaces:** -- Consumes only `PublicDynamicChoiceQuestion`, `DynamicJourneyProgress`, and controller callbacks. -- Removes v2 imports/usages of `BirthTimeGuideTurn` and `BirthTimeEvidenceDraftCard` from the active rectification path. - -- [ ] **Step 1: Write failing UI contract tests** - -```ts -test("the v2 question surface is click-first", () => { - assert.match(choiceSource, /question\.options\.map/); - assert.match(choiceSource, /onSelect\(option\.optionId\)/); - assert.doesNotMatch(choiceSource, /整理为经历草稿|记得的精度|发生时间|第.*\/.*轮/); - assert.doesNotMatch(choiceSource, /