chore(dev-stack): post-walkthrough environment stabilization (C bundle)#47
Merged
Conversation
Three sub-tasks distilled from the Manual Walkthrough Verification (PR #40~#46) into a single dev-stack chore. Closes the env-side blockers that prevented Tier 2 dynamic verification during the walkthrough. C1 — postgres dev volume disk-full * scripts/dev-reset.sh tears the project compose down -v + project-scoped volume prune + up -d (+ optional --rebuild-worker / --seed / --no-prompt). * Project-scoped via `--filter label=com.docker.compose.project=trustedoss-portal`, so other Compose stacks on the host are untouched. C2 — celery-worker stale image (`aiosmtplib` ModuleNotFoundError) * Dockerfile.worker now installs `requirements.txt` + `requirements-dev.txt` explicitly (not via the transitive `-r requirements.txt` inside requirements-dev.txt) to keep the dependency surface visible to a build-cache miss diagnostic and avoid a future pip behaviour change silently dropping it. * Makefile (NEW) adds `dev-rebuild-worker`, `dev-reset`, `dev-reset-rebuild`, `dev-up`, `dev-down`, `dev-logs`, `dev-ps` for routine ops. C3 — Vite proxy (option A: unify SPA + harness on /v1/*) * vite.config.ts gains a proxy block for /v1, /auth (HTTP) and /ws (WebSocket). Defaults target the docker-compose service `backend:8000`; override with VITE_PROXY_BACKEND / VITE_PROXY_WS when running Vite on the host outside compose. * NotificationsHarness / ProfileHarness / AdminBackupHarness fall back to `this.baseUrl` (Vite 5173, proxied) instead of the hard-coded `http://localhost:8000`. Same-origin path means cookies flow without cross-origin gymnastics. BACKEND_BASE_URL / VITE_API_BASE_URL retain bypass semantics for CI / cross-host runs. Docs * docs-site/docs/contributor-guide/getting-started.md (EN+KO) corrects the stale "/api proxy" mention and adds a "Reset the dev stack" section. Validation * bash -n scripts/dev-reset.sh * npx tsc --noEmit (frontend) — clean * npm test -- --run — 523/523 pass * npm run lint — 0 errors (17 pre-existing react-refresh warnings) * make help — Makefile parses Operator note: after merge, run `make dev-rebuild-worker` (or `docker-compose pull && docker-compose up -d --force-recreate`) once to pick up the worker image with the explicit pip install. CI rebuilds fresh per run so the GHA matrix is unaffected. Refs: docs/sessions/_next-session-prompt-post-walkthrough-stabilization.md §"세션 1 — 묶음 C: 환경 안정화" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The new Vite proxy block introduced `ws://backend:8000` as the dev-only default, which trips semgrep's `javascript.lang.security.detect-insecure- websocket` rule (ERROR severity → HARD FAIL gate). Production never runs the Vite dev server — Traefik fronts the static bundle with `wss://`. Suppress inline with the same `nosemgrep` form already used in `apps/frontend/src/lib/wsBase.ts`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
5 tasks
The per-line `nosemgrep` comment did not suffice — the rule appears to
match the entire `proxy: { ... }` object once it sees `ws: true`, so
both `/v1` and `/auth` HTTP targets also got flagged. Whole-file
suppression is the same approach already used for
`apps/frontend/src/lib/wsBase.ts`. Production never runs the Vite dev
server, so the dev-only `ws://backend:8000` default is not a deployment
concern.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cheme The previous comment block spelled `ws://backend:8000` verbatim to explain why vite.config.ts is ignored. semgrep's `detect-insecure-websocket` rule then flagged the .semgrepignore file itself on that line, defeating the suppression. Rewrite the comment to describe the scheme by name only — the rule matches on the literal byte sequence, not on prose meaning. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang
added a commit
that referenced
this pull request
May 10, 2026
CI on PR #49 surfaced the real blocker: scripts/backup.sh requires ``docker-compose`` (V1) inside the celery-worker container so it can shell back into the compose stack for ``pg_dump`` + ``alembic current``. The dev / CI worker images do not install docker-compose, so the backup task fails with ``BackupTaskError("backup.sh exited 1: docker- compose (V1) is required.")``. This is unrelated to the original "stale image / missing aiosmtplib" hypothesis the original PR #45 fixme cited — the C bundle worker rebuild (PR #47) does not fix it. Restore the fixme with the corrected reason and the three operator- side resolution paths (bake docker-compose into the worker / refactor backup.sh to use DATABASE_URL directly / use a pg_dump shim in tests). The toast assertion still runs; the row-creation post-condition stays deferred. D1 (seed --with-oauth-identity) keeps its full test resolution — the auth_and_profile test 4 (Unlink with password fallback) is the real value of this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang
added a commit
that referenced
this pull request
May 10, 2026
* chore(e2e): resolve Phase 5 fixme — OAuth seed + worker manual-trigger D bundle from the post-walkthrough stabilization queue. Two of the four manual-aligned fixme markers laid down in PR #45 are unblocked here: D1 — seed_e2e_user --with-oauth-identity {github,google} * New flag inserts one OAuthIdentity row pinned to the chosen provider for the primary user. provider_user_id = `e2e-<suffix>` so concurrent seed runs do not collide on the (provider, provider_user_id) unique index. The user keeps the seeded password — the OAuth identity is a secondary auth method, not a replacement. * tests/_harness/seed.ts mirrors the flag (`withOAuthIdentity`) and surfaces the seeded row's id / provider / provider_user_id back to the spec via the JSON summary. * auth_and_profile.spec.ts test 4 ("Unlink succeeds when password fallback exists") removed `test.fixme` and now drives the actual flow: seed with `withOAuthIdentity: 'github'` → login via password → expectConnectedAccounts(['github']) → unlinkProvider('github') → expectUnlinkSuccess → expectConnectedAccounts([]). * test 3 (last-only blocks-login alert) keeps fixme — the blocks-login branch only triggers when the user has no hashed_password (OAuth-only account) and the seed always provisions a password. Updating the fixme reason to point at the missing `--no-password` / JWT-mint follow-up; the URN itself is exercised by the backend integration suite (test_users_me_oauth_identities_api.py). D2 — admin_backup manual-trigger row check * admin_backup.spec.ts test 4 ("manual backup trigger creates a row") removed `test.fixme` (was deferred on the stale-worker premise from PR #45; the C bundle / `chore/dev-stack-stabilization` ships the worker rebuild path, and CI rebuilds fresh per run regardless). * AdminBackupHarness gains `waitForManualBackupRow(timeoutMs)` that polls the row count via `expect.poll` (no waitForTimeout — honours the test-writer.md gate). The spec captures the baseline row count before triggering and asserts it grows after, defending against a UI-only regression that toasts "manual_triggered" without actually persisting the row. Validation * npx tsc --noEmit (frontend) — clean * ruff + mypy on seed_e2e_user.py — clean CI runs the full e2e (manual-aligned) matrix against a fresh docker-compose stack — the local dev worker's stale image is unrelated because the GHA runner builds + brings the stack up per matrix shard. Refs: docs/sessions/_next-session-prompt-post-walkthrough-stabilization.md §"세션 3 — 묶음 D: Phase 5 fixme 해소" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(d-bundle): restore admin_backup test 4 fixme — backup.sh worker dep CI on PR #49 surfaced the real blocker: scripts/backup.sh requires ``docker-compose`` (V1) inside the celery-worker container so it can shell back into the compose stack for ``pg_dump`` + ``alembic current``. The dev / CI worker images do not install docker-compose, so the backup task fails with ``BackupTaskError("backup.sh exited 1: docker- compose (V1) is required.")``. This is unrelated to the original "stale image / missing aiosmtplib" hypothesis the original PR #45 fixme cited — the C bundle worker rebuild (PR #47) does not fix it. Restore the fixme with the corrected reason and the three operator- side resolution paths (bake docker-compose into the worker / refactor backup.sh to use DATABASE_URL directly / use a pg_dump shim in tests). The toast assertion still runs; the row-creation post-condition stays deferred. D1 (seed --with-oauth-identity) keeps its full test resolution — the auth_and_profile test 4 (Unlink with password fallback) is the real value of this PR. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang
added a commit
that referenced
this pull request
May 10, 2026
`Path(__file__).resolve().parent.parent` from `tests/integration/...` resolves to `tests/`, not `apps/backend/`. The `_migrate_once` fixture ran ``alembic upgrade head`` with `cwd=tests/` and silently skipped on non-zero exit (alembic.ini is not at that cwd). Every trigger test then skipped — explaining the +18 skipped count vs PR #47's 8 — and the test file's 250 lines landed at 0% coverage, dragging the project total to 34%. Mirror the path computation other ``tests/integration/test_*.py`` files use: ``.parent.parent.parent`` (3 levels up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang
added a commit
that referenced
this pull request
May 10, 2026
* fix: walkthrough system bugs A1+A2+A3 + manual reverse-drift Three sub-tasks distilled from the Manual Walkthrough Verification system bug list (PR #44 alignment + walkthrough sessions 2026-05-09 / -10). A1 — audit_logs DB-level immutability (sys-bug-audit-1) * Alembic migration 0012 attaches two triggers: - audit_logs_immutable_trigger — BEFORE UPDATE OR DELETE, FOR EACH ROW. - audit_logs_immutable_truncate — BEFORE TRUNCATE, FOR EACH STATEMENT (PostgreSQL fires row triggers only on UPDATE/DELETE; without the statement-level guard a single TRUNCATE TABLE would silently wipe the audit trail despite the row trigger). * Function audit_logs_prevent_mutation() raises SQLSTATE 23000 carrying the offending TG_OP — surfaces as IntegrityError in SQLAlchemy. * INSERT path is unaffected; the audit listener stays functional. * Integration test (test_audit_log_db_immutable.py) covers INSERT succeeds, UPDATE / DELETE / TRUNCATE blocked, multi-column UPDATE blocked, zero-row UPDATE no-op, JSONB overwrite blocked, plus an adversarial parametrize over 8 columns (action / ip / user_agent / request_id / actor_user_id / target_id / target_table / created_at). A2 — restore 412 + RFC 7807 URN (sys-bug-bkp-1) * /v1/admin/backup/restore now returns 412 Precondition Failed (was 400) when X-Confirm-Restore: yes is missing. RFC 9110 §15.5.13 fit: the request shape is well-formed; what is missing is the destructive- restore precondition. * New URN: urn:trustedoss:problem:restore_confirmation_required (matches the convention used by users_me / oauth_identity_service for new errors; the legacy https://docs.trustedoss.io/errors/* URIs in this file stay because their wire forms are publicly documented). * Title: "Restore confirmation header missing". * test_restore_without_confirm_header_returns_412 asserts 412 + the problem URN + the new title. A3 — audit CSV UTF-8 BOM (sys-bug-audit-2) * services/admin_audit_service.py prepends to the header chunk in stream_audit_csv. Excel on Korean / Japanese / Chinese locales now auto-detects UTF-8 instead of decoding the export under CP949 / SJIS / GB18030 (mojibake). Tools that already auto-detect UTF-8 (LibreOffice, awk, Python's csv / utf-8-sig codecs) silently strip the BOM. * test_admin_audit_export_csv_streams asserts the first 3 wire bytes are 0xEF 0xBB 0xBF; the unit test test_stream_audit_csv_yields_header_and_rows asserts the same at the service layer. Manual reverse-drift (docs-site, EN + KO) * admin-guide/audit-log.md — flips the schema description from "enforced at the application layer (DB-level on roadmap)" to "enforced at two layers"; lifts UTF-8 BOM + DB immutability out of the v2.x Roadmap (now shipped); rewrites the retention-purge runbook to drop both triggers and verify with a pg_trigger query before closing the maintenance session. * admin-guide/backup-and-restore.md — flips the restore confirmation description from 400 to 412 + the new URN; lifts HTTP 412 out of the v2.x Roadmap. Backward-compat note: PR #44 had aligned the manual to 400 to match the shipped behaviour; this PR fixes the shipped behaviour and reverse-drifts the manual back to 412. Producer-Reviewer (security-reviewer pass) * PASS WITH MINOR CHANGES; M1 (TRUNCATE bypass) + L2 (parametrize expansion) + L3 (runbook verification) all addressed in this PR. * L1 (split runtime / migration role) deferred to Phase 7 / 8 hardening and documented in the migration docstring + admin-guide/audit-log.md as a known residual bypass surface. Validation * ruff check on changed files — clean * mypy on changed source — clean * AST parse of new migration + new test — clean * Backend tests + Docusaurus build run in CI (the local dev DB is unhealthy and worker-stale; PR #47 / chore C bundle fixes the environment). Refs: docs/sessions/_next-session-prompt-post-walkthrough-stabilization.md §"세션 2 — 묶음 A: 시스템 버그 fix" Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(a-bundle): trigger now allows FK cascade SET NULL on actor_user_id/team_id CI on PR #48 surfaced the exact bypass case the security-reviewer's I1 finding said did NOT exist: deleting a User or Team triggers Postgres' ON DELETE SET NULL cascade, which UPDATEs every prior audit_logs row to NULL its FK column. The original blanket trigger refused that UPDATE, so test_admin_team_service / test_admin_team_delete_concurrency / test_delete_team_archives_projects_then_succeeds all 500'd with ``audit_logs is append-only (TG_OP=UPDATE)``. Function rewrite (still SQLSTATE 23000 → IntegrityError): - TRUNCATE / DELETE — refused unconditionally. - UPDATE — gated in two stages: 1. Content columns (id / created_at / action / target_table / target_id / request_id / ip / user_agent / diff) must be byte-identical between OLD and NEW. Any change → tampering → refused. ``IS DISTINCT FROM`` so NULL-vs-NULL is a no-op. 2. actor_user_id / team_id may transition only to NULL. The function gates on ``NEW.<col> IS NOT NULL AND OLD.<col> IS DISTINCT FROM NEW.<col>`` — the legitimate FK cascade SET NULL passes through; rotating between two non-NULL ids ("framing" attacks) is refused. The error message names the column so operators see which pin tripped. Tests retargeted to the new contract: - actor_user_id / created_at / target_id / target_table parametrize cases continue to assert IntegrityError on content-mutation. The actor_user_id non-NULL UUID case now falls under the dedicated ``test_actor_user_id_rotate_to_other_id_blocked`` (asserts the "actor_user_id pin" message variant); ditto team_id. - New ``test_actor_user_id_set_null_via_cascade_allowed`` and ``test_team_id_set_null_via_cascade_allowed`` lock in the legitimate cascade path so a future migration tightening the trigger cannot silently break User / Team delete. - test_alembic_upgrade.py expected head bumped 0011 → 0012. The migration docstring grows a "FK cascade SET NULL is allowed" clause documenting the carve-out in the same place the strict content-immutability claim lives. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(a-bundle): BACKEND_ROOT in audit-log immutability test `Path(__file__).resolve().parent.parent` from `tests/integration/...` resolves to `tests/`, not `apps/backend/`. The `_migrate_once` fixture ran ``alembic upgrade head`` with `cwd=tests/` and silently skipped on non-zero exit (alembic.ini is not at that cwd). Every trigger test then skipped — explaining the +18 skipped count vs PR #47's 8 — and the test file's 250 lines landed at 0% coverage, dragging the project total to 34%. Mirror the path computation other ``tests/integration/test_*.py`` files use: ``.parent.parent.parent`` (3 levels up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(a-bundle): asyncpg-compatible bindings in trigger tests Two bugs the BACKEND_ROOT fix exposed (the previous run skipped the whole module so neither was reached): 1. ``test_update_any_content_column_blocked[created_at-…]`` Pass ``datetime(2020, 1, 1, tzinfo=UTC)`` instead of the ISO string ``'2020-01-01 00:00:00+00'``. asyncpg's TIMESTAMPTZ codec expects a real datetime instance and raises ``DataError: invalid input for query argument $1`` on a bare string. 2. ``test_diff_jsonb_overwrite_blocked`` ``UPDATE … SET diff = :diff::jsonb …`` — asyncpg's parameter binder reads ``::`` as a named-parameter delimiter, so the statement raises ``PostgresSyntaxError: syntax error at or near ":"`` before reaching the trigger. ``CAST(:diff AS jsonb)`` is the same semantic without the lexer collision. Coverage on the previous run already came back to 86.39% so the BACKEND_ROOT fix worked; these are the residual functional bugs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 10, 2026
haksungjang
added a commit
that referenced
this pull request
May 10, 2026
) Session-end cleanup for the C / A / D bundles (PR #47 / #48 / #49 + PR #50 lockfile fix-out). * `docs/sessions/2026-05-10-stabilization-cad-bundle.md` (NEW) — 통합 핸드오프 노트, `docs/v2-execution-plan.md` §7 양식 follow. CI 라운드트립에서 노출된 4개의 추가 버그 (semgrep self-trip / trigger over-blocking on FK cascade / BACKEND_ROOT path / asyncpg binding) 와 backup.sh worker shell-back 차단 사유, A1 trigger 의 최종 plpgsql 본문, security-reviewer M1/L2/L3 반영 결과, Memory 업데이트 권고 4건 포함. * `docs/chore-backlog.md` — Manual Walkthrough Verification 섹션 갱신: - sys-bug-audit-1 / -bkp-1 / -audit-2 ~~strikethrough~~ + PR #48 - postgres prune / worker rebuild / Vite proxy ~~strikethrough~~ + PR #47 - auth_and_profile test 4 ~~strikethrough~~ + PR #49 - admin_backup test 4 + auth_and_profile test 3 fixme 유지 사유 정정 (backup.sh worker dep / OAuth-only fixture 부재) - sys-bug-u&t-1 + sys-bug-dt-1 잔여 → "추후 fix 후보" 라벨링 - "Deploy Docs CI 회복" 신규 항목 (PR #50 lockfile commit) - "다음 세션 후보" 섹션 5 옵션 (A4/A5 chore / L1 role split / D2 backup refactor / D1 no-password seed / B v2.1 sprint) - 본 prompt 파일 deprecated 마크 추가 * `docs/sessions/_next-session-prompt-post-walkthrough-stabilization.md` (NEW track) — DEPRECATED 마커 추가. C/A/D + 부산물 lockfile 머지 commit sha 4건 + 통합 핸드오프 링크. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
haksungjang
added a commit
that referenced
this pull request
May 28, 2026
This commit consolidates the docs/UX/automation artefacts accumulated in the 2026-05-27 session that drove the W8-#46 fix (PR #231) and registered four new waves of follow-up. Scope: - scripts/scan-bench/: Playwright/curl-free Python orchestrator that registers fx-* projects, uploads source zips, triggers scans, polls, and emits CSV/markdown/jsonl per run. Drives W8 wave discovery. - docs/scans/{fixture-matrix,realworld-benchmark}-2026-05-27.md: results from bd-scan fixture × 32 + Juice Shop / WebGoat / TrustedOSS v1 selfscan. Surfaces W8-#46 (P0 GA blocker, fixed in #231), #47–#49. - docs/ux/competitive-audit-{plan,}-2026-05-27.md + raw/* + screens/*: Our 8 screens × 5 competitors (BD/Snyk/Sonatype/Mend/Datadog) × 8 axes matrix. Score 3.6/5, second only to BD/Datadog (4.0). Produces W9 wave. - apps/frontend/tests/e2e/ux-audit/capture-ours.spec.ts: 1-command Playwright capture (EN UI, 2x DPR, dual-purpose audit + docs SoT). - docs/ux/design-philosophy-evolution-plan-2026-05-27.md: user-confirmed W10 (drawer dual surface, 4.5d, absorbs W9-#51-A/B) + W11 (visual identity, light single, option C Vercel base + Linear polish, ~11d). - docs/ux/reference/: 9 PNG (Linear 4 + Vercel 5) backing the option C decision; 3 archive .bak hero-gradient files we couldn't rm in-session. - docs/sessions/2026-05-27-*.md: three handoffs (scan-detection-benchmark, ux-competitive-audit, design-philosophy-evolution-decision) following v2-execution-plan §7. - docs/post-ga-execution-tracker.md: dashboard adds W8/W9/W10/W11 + v2.4.0 GA ordering paragraph (UX/Design first → docs → GA, first public release impression priority); full body entries for each new wave + W8-#46 status flip to ✅ (PR #231). Hetzner SaaS hosting artefacts (docs/operator-runbook-hetzner.md and docs/sessions/2026-05-28-demo-saas-hetzner-decision.md) are *not* included here — they belong to a parallel session's track. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three sub-tasks distilled from the Manual Walkthrough Verification (PR #40 ~ #46) into one dev-stack chore. Closes the env-side blockers that prevented Tier 2 dynamic verification during the walkthrough.
scripts/dev-reset.sh(project-scoped down -v + volume prune + up)aiosmtplibModuleNotFoundError on a 3-day-old worker image (PR #18 added the dep)pip install -r requirements.txt -r requirements-dev.txtinDockerfile.worker+Makefiledev-rebuild-workertargetlocalhost:8000cross-origin →__authStore.accessTokenbridge neededvite.config.tsproxy block for/v1,/auth,/ws; harness fallback chain ends atthis.baseUrl(5173)Files
apps/backend/Dockerfile.worker— explicit two-file pip install (defensive, surfaces dependency surface to cache-miss diagnostics)Makefile(NEW) —dev-up,dev-down,dev-rebuild-worker,dev-reset,dev-reset-rebuild,dev-logs,dev-psscripts/dev-reset.sh(NEW) — project-scoped reset;--rebuild-worker,--seed,--no-promptflagsapps/frontend/vite.config.ts— proxy block; targets configurable viaVITE_PROXY_BACKEND/VITE_PROXY_WSapps/frontend/tests/_harness/{Notifications,Profile,AdminBackup}Harness.ts—backendBaseUrl()falls back tothis.baseUrlinstead of hard-codedhttp://localhost:8000docs-site/docs/contributor-guide/getting-started.md(EN+KO) — corrects the stale/apiproxy mention and documentsReset the dev stackValidation
bash -n scripts/dev-reset.sh— syntax OKmake help— Makefile parsesnpx tsc --noEmit(frontend) — cleannpm test -- --run— 523 / 523 passnpm run lint— 0 errors (17 pre-existingreact-refreshwarnings)The CI matrix (e2e [scan-flow] + e2e [manual-aligned]) will validate the proxy + harness change end-to-end against a fresh compose stack.
Operator note (post-merge)
After merge, run
make dev-rebuild-worker(ordocker-compose pull && docker-compose up -d --force-recreate) once locally to pick up the worker image with the explicit pip install. CI rebuilds fresh per run so the GHA matrix is unaffected.Test plan
e2e (scan-flow)39 scenariose2e (manual-aligned)27 scenariosRefs:
docs/sessions/_next-session-prompt-post-walkthrough-stabilization.md§"세션 1 — 묶음 C: 환경 안정화"🤖 Generated with Claude Code