From bf7939dd1ad8b3914e1075db0f6618b1812012ba Mon Sep 17 00:00:00 2001 From: Wyndham ARR Date: Fri, 31 Jul 2026 15:11:42 +0800 Subject: [PATCH] feat: sync latest ARR implementation --- .env.example | 43 +- .project-docs/00-brief/project-positioning.md | 8 +- .project-docs/00-brief/success-criteria.md | 7 +- .project-docs/05-agent-entry/memory-index.md | 22 +- ...monthly-trigger-and-total-price-formula.md | 19 +- ...legates-validation-to-system-boundaries.md | 41 + ...blic-read-source-url-for-fetch-oss-file.md | 59 + ...R-004-arr-owned-programmatic-processing.md | 52 + .project-docs/10-decisions/decision-index.md | 20 +- .project-docs/20-architecture/data-flow.md | 61 +- .project-docs/20-architecture/module-map.md | 50 +- .../20-architecture/system-overview.md | 52 +- .project-docs/30-worklog/current-state.md | 328 +++- .project-docs/30-worklog/task-history.md | 56 + .project-docs/40-domain/business-rules.md | 64 +- .project-docs/50-evidence/evidence-index.md | 31 +- ...hannel-bi-database-runtime-verification.md | 41 + ...7-29-local-xml-upload-runtime-reenabled.md | 8 +- ...2026-07-29-public-deployment-repository.md | 6 + ...7-29-repeated-xml-artifact-identity-fix.md | 40 + ...-superagent-manual-xml-run-bypassed-mcp.md | 51 + .../topics/2026-07-29-task-trace-terminal.md | 109 ++ .../2026-07-30-arr2-programmatic-pipeline.md | 32 + ...annel-bi-post-update-data-contamination.md | 116 ++ ...any-report-path-and-month-end-readiness.md | 98 ++ .../2026-07-30-daily-page-visual-polish.md | 40 + ...-07-30-daily-upload-filename-provenance.md | 53 + .../2026-07-30-first-live-arr2-user-run.md | 69 + ...-30-monthly-publication-live-acceptance.md | 89 + ...-superagent-fetch-oss-prompt-experiment.md | 205 +++ ...026-07-30-task-log-relocation-and-scope.md | 50 + .../2026-07-30-web-login-runtime-mismatch.md | 77 + .../2026-07-31-booking-extraction-program.md | 84 + ...ing-import-dimensions-and-live-database.md | 84 + ...7-31-booking-manual-review-schema-audit.md | 90 + ...026-07-31-company-action-button-compact.md | 32 + ...-31-company-card-row-and-confirm-dialog.md | 35 + ...2026-07-31-company-detail-header-upload.md | 28 + .../2026-07-31-company-period-card-copy.md | 32 + ...6-07-31-company-report-early-generation.md | 31 + .../2026-07-31-daily-kpi-card-height.md | 32 + .../2026-07-31-daily-overview-layout.md | 35 + ...-daily-upload-progress-and-log-behavior.md | 36 + ...ive-company-job-booking-room-diagnostic.md | 34 + ...-07-31-markdown-channel-baseline-freeze.md | 72 + ...markdown-current-source-release-recheck.md | 41 + ...026-07-31-standard-monthly-page-cleanup.md | 35 + ...e-stream-semantics-before-trace-capture.md | 35 + ...untime-target-and-entrypoint-before-e2e.md | 44 + ...d-automation-must-reconcile-the-open-ui.md | 38 + ...inate-hot-static-and-long-lived-backend.md | 54 + ...urrent-projection-must-exclude-fixtures.md | 46 + ...e-report-watermarks-from-business-facts.md | 45 + ...nguish-snapshot-from-batch-completeness.md | 45 + ...eterministic-results-machine-to-machine.md | 45 + ...-07-31-formatting-is-not-business-state.md | 42 + .../60-reflection/reflection-index.md | 9 + .project-docs/80-commitments/commitments.md | 18 +- .project-docs/90-maintenance/stale-items.md | 27 +- AGENT_INTEGRATION.md | 22 +- AGENT_WRITEBACK_CONTRACT.md | 8 +- CHECKSUMS.sha256 | 6 +- DATABASE_SCHEMA_DICTIONARY.md | 18 +- DATA_PROCESSING_HANDOFF.md | 13 +- Dockerfile | 6 +- README.md | 131 +- SUPERAGENT_PROFILE_V3_MANUAL_CONFIG.md | 4 +- SUPERAGENT_PROFILE_V4_MCP_CONFIG.md | 4 +- agent_integration/client.py | 3 +- agent_integration/service.py | 2 + arr_database.py | 52 + arr_ingestion/README.md | 118 +- arr_ingestion/contracts.py | 2 + arr_ingestion/postgres.py | 161 +- arr_ingestion/repository.py | 30 + arr_ingestion/validation.py | 2 + arr_processing/README.md | 60 +- arr_processing/agent_trace.py | 469 ++++++ arr_processing/local.py | 155 ++ arr_processing/open_agent.py | 214 ++- arr_processing/policy.py | 35 + arr_processing/postgres.py | 1 + arr_processing/source_message.py | 39 +- arr_storage/README.md | 86 +- arr_storage/aliyun_oss_v2.py | 15 +- arr_storage/contracts.py | 1 + arr_web/README.md | 119 +- arr_web/agent_writeback_runtime.py | 31 +- arr_web/app.py | 484 +++++- arr_web/auth.py | 157 ++ arr_web/booking_uploads.py | 287 ++++ arr_web/company_jobs.py | 46 +- arr_web/contracts.py | 7 + arr_web/direct_ingestion_runtime.py | 2 +- arr_web/downloads.py | 76 +- arr_web/job_trace.py | 654 ++++++++ arr_web/legacy_agent_services.py | 143 ++ arr_web/processing_runtime.py | 122 +- arr_web/programmatic.py | 236 +++ arr_web/repository.py | 468 +++++- arr_web/run.py | 133 +- arr_web/server.py | 10 + arr_web/services.py | 210 +-- arr_web/static/app.js | 1456 +++++++++++++++-- arr_web/static/h5.css | 10 +- arr_web/static/h5.html | 9 +- arr_web/static/h5.js | 37 +- arr_web/static/index.html | 345 ++-- arr_web/static/login.css | 250 +++ arr_web/static/login.html | 75 + arr_web/static/login.js | 77 + arr_web/static/styles.css | 456 +++++- booking_ingestion/__init__.py | 24 + booking_ingestion/excel.py | 722 ++++++++ booking_ingestion/excel_postgres.py | 722 ++++++++ booking_ingestion/excel_review.py | 179 ++ booking_ingestion/excel_review_postgres.py | 944 +++++++++++ company_reports/README.md | 5 +- company_reports/contracts.py | 9 +- company_reports/core.py | 108 +- compose.yaml | 80 +- .../011_artifact_object_identity.down.sql | 34 + database/011_artifact_object_identity.sql | 42 + .../012_monthly_report_publication.down.sql | 37 + database/012_monthly_report_publication.sql | 281 ++++ database/013_daily_upload_filename.down.sql | 37 + database/013_daily_upload_filename.sql | 47 + .../014_booking_current_source_batch.down.sql | 72 + database/014_booking_current_source_batch.sql | 118 ++ .../015_booking_excel_review_drafts.down.sql | 30 + database/015_booking_excel_review_drafts.sql | 123 ++ database/APPLIED_MIGRATIONS.md | 102 +- deploy/.env.production.example | 35 +- deploy/Caddyfile | 16 - deploy/README.md | 158 +- monthly_reports/README.md | 28 +- monthly_reports/contracts.py | 2 +- monthly_reports/core.py | 5 + monthly_reports/publishing.py | 9 +- monthly_reports/repository.py | 483 +++++- monthly_reports/service.py | 20 + monthly_reports/worker.py | 447 +++++ monthly_reports/xlsx/build_workbook.mjs | 34 +- prompts/arr_opera_daily_main_agent_prompt.md | 243 +-- .../arr_opera_daily_program_input.schema.json | 10 +- requirements-oss.txt | 1 + requirements.txt | 1 - tests/test_agent_client.py | 13 +- tests/test_agent_service.py | 10 +- tests/test_arr_agent_trace.py | 133 ++ tests/test_arr_end_to_end.py | 2 +- tests/test_arr_ingestion_postgres.py | 204 ++- tests/test_arr_opera_daily_ingest.py | 27 +- tests/test_arr_oss_v2.py | 40 +- tests/test_arr_processing.py | 139 +- tests/test_arr_programmatic.py | 108 ++ tests/test_arr_storage.py | 12 + tests/test_arr_web.py | 645 ++++++-- tests/test_arr_web_auth.py | 218 +++ tests/test_arr_web_booking_uploads.py | 279 ++++ tests/test_arr_web_company_reports.py | 352 +++- tests/test_arr_web_daily_visual_ui.py | 83 + tests/test_arr_web_job_trace.py | 193 +++ tests/test_arr_web_repository_schema.py | 83 +- tests/test_arr_web_server.py | 151 ++ tests/test_arr_web_task_log_ui.py | 79 + ...test_artifact_object_identity_migration.py | 34 + .../test_booking_current_source_migration.py | 41 + tests/test_booking_excel_ingestion.py | 203 +++ tests/test_booking_excel_review.py | 83 + tests/test_booking_excel_review_postgres.py | 104 ++ tests/test_booking_ingestion.py | 12 + tests/test_booking_review_migration.py | 33 + tests/test_company_reports_core.py | 89 +- tests/test_company_reports_integration.py | 57 + tests/test_company_reports_service.py | 34 +- tests/test_daily_upload_filename_migration.py | 42 + tests/test_deployment_entrypoints.py | 23 + tests/test_monthly_publication_migration.py | 49 + tests/test_monthly_reports_core.py | 18 +- tests/test_monthly_reports_publishing.py | 4 +- tests/test_monthly_reports_repository.py | 75 +- tests/test_monthly_reports_service.py | 52 +- tests/test_monthly_reports_worker.py | 133 ++ tests/test_monthly_reports_xlsx.py | 19 +- 185 files changed, 17527 insertions(+), 2260 deletions(-) create mode 100644 .project-docs/10-decisions/ADR-002-controlled-agent-delegates-validation-to-system-boundaries.md create mode 100644 .project-docs/10-decisions/ADR-003-public-read-source-url-for-fetch-oss-file.md create mode 100644 .project-docs/10-decisions/ADR-004-arr-owned-programmatic-processing.md create mode 100644 .project-docs/50-evidence/topics/2026-07-29-channel-bi-database-runtime-verification.md create mode 100644 .project-docs/50-evidence/topics/2026-07-29-repeated-xml-artifact-identity-fix.md create mode 100644 .project-docs/50-evidence/topics/2026-07-29-superagent-manual-xml-run-bypassed-mcp.md create mode 100644 .project-docs/50-evidence/topics/2026-07-29-task-trace-terminal.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-arr2-programmatic-pipeline.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-channel-bi-post-update-data-contamination.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-company-report-path-and-month-end-readiness.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-daily-page-visual-polish.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-daily-upload-filename-provenance.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-first-live-arr2-user-run.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-monthly-publication-live-acceptance.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-superagent-fetch-oss-prompt-experiment.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-task-log-relocation-and-scope.md create mode 100644 .project-docs/50-evidence/topics/2026-07-30-web-login-runtime-mismatch.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-booking-extraction-program.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-booking-import-dimensions-and-live-database.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-booking-manual-review-schema-audit.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-company-action-button-compact.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-company-card-row-and-confirm-dialog.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-company-detail-header-upload.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-company-period-card-copy.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-company-report-early-generation.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-daily-kpi-card-height.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-daily-overview-layout.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-daily-upload-progress-and-log-behavior.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-live-company-job-booking-room-diagnostic.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-markdown-channel-baseline-freeze.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-markdown-current-source-release-recheck.md create mode 100644 .project-docs/50-evidence/topics/2026-07-31-standard-monthly-page-cleanup.md create mode 100644 .project-docs/60-reflection/cases/2026-07-29-probe-stream-semantics-before-trace-capture.md create mode 100644 .project-docs/60-reflection/cases/2026-07-29-verify-runtime-target-and-entrypoint-before-e2e.md create mode 100644 .project-docs/60-reflection/cases/2026-07-30-backend-automation-must-reconcile-the-open-ui.md create mode 100644 .project-docs/60-reflection/cases/2026-07-30-coordinate-hot-static-and-long-lived-backend.md create mode 100644 .project-docs/60-reflection/cases/2026-07-30-current-projection-must-exclude-fixtures.md create mode 100644 .project-docs/60-reflection/cases/2026-07-30-derive-report-watermarks-from-business-facts.md create mode 100644 .project-docs/60-reflection/cases/2026-07-30-distinguish-snapshot-from-batch-completeness.md create mode 100644 .project-docs/60-reflection/cases/2026-07-30-keep-deterministic-results-machine-to-machine.md create mode 100644 .project-docs/60-reflection/cases/2026-07-31-formatting-is-not-business-state.md create mode 100644 arr_database.py create mode 100644 arr_processing/agent_trace.py create mode 100644 arr_processing/local.py create mode 100644 arr_processing/policy.py create mode 100644 arr_web/auth.py create mode 100644 arr_web/booking_uploads.py create mode 100644 arr_web/job_trace.py create mode 100644 arr_web/legacy_agent_services.py create mode 100644 arr_web/programmatic.py create mode 100644 arr_web/static/login.css create mode 100644 arr_web/static/login.html create mode 100644 arr_web/static/login.js create mode 100644 booking_ingestion/excel.py create mode 100644 booking_ingestion/excel_postgres.py create mode 100644 booking_ingestion/excel_review.py create mode 100644 booking_ingestion/excel_review_postgres.py create mode 100644 database/011_artifact_object_identity.down.sql create mode 100644 database/011_artifact_object_identity.sql create mode 100644 database/012_monthly_report_publication.down.sql create mode 100644 database/012_monthly_report_publication.sql create mode 100644 database/013_daily_upload_filename.down.sql create mode 100644 database/013_daily_upload_filename.sql create mode 100644 database/014_booking_current_source_batch.down.sql create mode 100644 database/014_booking_current_source_batch.sql create mode 100644 database/015_booking_excel_review_drafts.down.sql create mode 100644 database/015_booking_excel_review_drafts.sql create mode 100644 monthly_reports/worker.py create mode 100644 tests/test_arr_agent_trace.py create mode 100644 tests/test_arr_programmatic.py create mode 100644 tests/test_arr_web_auth.py create mode 100644 tests/test_arr_web_booking_uploads.py create mode 100644 tests/test_arr_web_daily_visual_ui.py create mode 100644 tests/test_arr_web_job_trace.py create mode 100644 tests/test_arr_web_server.py create mode 100644 tests/test_arr_web_task_log_ui.py create mode 100644 tests/test_artifact_object_identity_migration.py create mode 100644 tests/test_booking_current_source_migration.py create mode 100644 tests/test_booking_excel_ingestion.py create mode 100644 tests/test_booking_excel_review.py create mode 100644 tests/test_booking_excel_review_postgres.py create mode 100644 tests/test_booking_review_migration.py create mode 100644 tests/test_daily_upload_filename_migration.py create mode 100644 tests/test_monthly_publication_migration.py create mode 100644 tests/test_monthly_reports_worker.py diff --git a/.env.example b/.env.example index d853abe..5ba446a 100644 --- a/.env.example +++ b/.env.example @@ -1,44 +1,25 @@ -DEERFLOW_BASE_URL=https://superagent.nianxx.cn -DEERFLOW_OPEN_API_KEY=df_open_replace_me -DEERFLOW_AUTH_MODE=bearer -DEERFLOW_TIMEOUT_SECONDS=60 -DEERFLOW_CONNECT_TIMEOUT_SECONDS=10 -DEERFLOW_SESSION_DB=runtime/agent_sessions.sqlite3 +# ARR 2.0 owns processing, validation and database ingestion. No Agent/MCP +# endpoint, prompt, API key, callback secret or public source URL is required. -# Independent bearer used only by the ARR MCP HTTP transport. -ARR_MCP_BEARER_TOKEN=replace_with_independent_random_token +# Application-owned Finance portal login. Inject real values through the +# runtime secret manager; use a long random password and never commit it. +ARR_WEB_USERNAME=arr-operator +ARR_WEB_PASSWORD=replace_with_a_long_random_secret -# Independent callback-verification secret shared only by the trusted Agent -# runtime adapter and ARR backend. Do not reuse DEERFLOW_OPEN_API_KEY. -ARR_AGENT_RESULT_HMAC_KEY_ID=arr-callback-key-1 -ARR_AGENT_RESULT_HMAC_KEY_B64=base64_of_at_least_32_random_bytes -ARR_AGENT_RESULT_MAX_AGE_SECONDS=600 -# Public HTTPS URL reached by the trusted Agent runtime adapter. -ARR_AGENT_CALLBACK_URL=https://arr.example.com/api/integrations/super-agent/results - -# ARR backend only. SuperAgent/Agent must never receive this value. -# Use an ARR application role with least privilege before production rollout. +# Must target the isolated PostgreSQL database named booking_test. ARR_DATABASE_URL=postgresql://arr_app:replace_me@127.0.0.1:5432/booking_test?sslmode=require -# Optional read/report role override; defaults to ARR_DATABASE_URL when omitted. +# Optional read/report role overrides; each defaults to ARR_DATABASE_URL. MONTHLY_REPORT_DATABASE_URL=postgresql://arr_report:replace_me@127.0.0.1:5432/booking_test?sslmode=require -# Optional least-privilege read-only BI role; defaults to ARR_DATABASE_URL. DASHBOARD_DATABASE_URL=postgresql://arr_dashboard:replace_me@127.0.0.1:5432/booking_test?sslmode=require -# Non-secret object-store routing only. Credentials belong in platform secret/instance-role configuration. -ARR_OBJECT_PROVIDER=oss +# ARR-managed objects are written with private object ACLs. The bucket itself +# may be private or public-read, but public-read-write and versioning are rejected. ARR_OBJECT_PREFIX=arr -ARR_OBJECT_BUCKET_ALIAS=arr-private-processing -ARR_OBJECT_GRANT_TTL_SECONDS=300 - -# Alibaba Cloud OSS SDK V2 routing. Keep the bucket private and unversioned; -# the guarded object workflow rejects Enabled or Suspended versioning. ARR_OSS_REGION=cn-hangzhou ARR_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com -ARR_OSS_BUCKET=replace-with-private-bucket -ARR_AGENT_OUTPUT_PREFIX=arr-agent-outputs +ARR_OSS_BUCKET=replace-with-bucket -# OSS SDK credentials are injected by RAM role / STS / deployment Secret. -# They are shown only as variable names; never commit real values. +# Prefer RAM role / STS injection. Never commit real credentials. OSS_ACCESS_KEY_ID=replace_me OSS_ACCESS_KEY_SECRET=replace_me # OSS_SESSION_TOKEN=replace_me diff --git a/.project-docs/00-brief/project-positioning.md b/.project-docs/00-brief/project-positioning.md index a115951..65692b5 100644 --- a/.project-docs/00-brief/project-positioning.md +++ b/.project-docs/00-brief/project-positioning.md @@ -6,7 +6,7 @@ ARR 是一个面向酒店 Finance 的 Opera XML 数据接入、验证入库与 ## Primary Goal -用户只需上传 Opera XML;Agent 处理后提交结构化结果,业务系统验证并写入数据库,再由后台自动触发月报等下游处理程序。 +用户只需上传 Opera XML;ARR 自己运行固定处理器、验证完整结果并写入数据库,再由后台自动触发月报等下游处理程序。 ## Target Users / Consumers @@ -20,15 +20,15 @@ ARR 是一个面向酒店 Finance 的 Opera XML 数据接入、验证入库与 ## Core Constraints -- 只有 Agent 结构化结果成功验证并提交数据库后,才允许触发下游月报程序。 +- 只有 ARR 程序化结果成功验证并提交数据库后,才允许触发下游月报程序。 - 月报所需的年月和截止范围由业务系统内部推导;内部审计字段不得变成用户输入要求。 - 工作簿必须保留 `TOTAL PRICE` 字段,其数据行公式为 `REAL PRICE × NIGHTS × NO_OF_ROOMS`。 - 数据处理应确定、可追溯、隐私最小化并支持安全重试。 ## Quality Bar -正确实现应让上传、Agent 回写、数据库提交和报表触发形成可验证的完整链路,并通过自动化测试证明字段、公式与触发边界。 +正确实现应让上传、固定处理、独立验证、数据库提交和报表触发形成可验证的完整链路,并通过自动化测试证明字段、公式与触发边界。 ## Last Reviewed -2026-07-29 +2026-07-30 diff --git a/.project-docs/00-brief/success-criteria.md b/.project-docs/00-brief/success-criteria.md index cd957f5..b26ffa6 100644 --- a/.project-docs/00-brief/success-criteria.md +++ b/.project-docs/00-brief/success-criteria.md @@ -2,8 +2,11 @@ ## Project Success +- 未登录用户只能看到 ARR 登录页或无详情 readiness;页面、API、上传、日志和下载均由服务端会话保护,退出后立即失效。 +- XML 上传、确定性处理、完整结果验证和 Finance 提交不依赖 Agent/MCP,并得到终态任务回执。 - 用户上传 XML 后无需再提交月报参数;结构化结果成功入库会自动触发相应月报处理。 - 生成的月报包含 `TOTAL PRICE` 字段,并在每个数据行使用 `REAL PRICE × NIGHTS × NO_OF_ROOMS` 的 Excel 公式。 +- 月报发布完成后,打开的月报页面自动出现新版本,不要求用户点击刷新。 ## Task Completion Standard @@ -12,10 +15,12 @@ ## Quality Checks +- 测试必须覆盖错误/正确登录、尝试限流、安全回跳、CSRF 退出、会话撤销、桌面/H5 会话过期处理,以及 375/768/1024/1440 响应式无横向溢出。 - 测试必须证明月报触发发生在数据库成功提交之后,且失败提交不会触发。 - 重新打开生成的 XLSX,确认 `TOTAL PRICE` 为公式单元格并逐行引用同一行的 `REAL PRICE`、`NIGHTS`、`NO_OF_ROOMS`。 - 用户入口不得要求提交月报年份、月份或截止日期。 +- 页面自动同步必须避免重叠请求;页面隐藏或离开月报页时停止轮询,恢复可见时立即重新读取。 ## Last Reviewed -2026-07-29 +2026-07-30 diff --git a/.project-docs/05-agent-entry/memory-index.md b/.project-docs/05-agent-entry/memory-index.md index 5904f3e..d307be5 100644 --- a/.project-docs/05-agent-entry/memory-index.md +++ b/.project-docs/05-agent-entry/memory-index.md @@ -1,25 +1,19 @@ # Memory Index -Use this as the high-density entry point before reading slower documents. - ## Startup Set - Project identity: `.project-docs/00-brief/project-positioning.md` - Current state: `.project-docs/30-worklog/current-state.md` -- Decision list: `.project-docs/10-decisions/decision-index.md` +- Active decisions: `.project-docs/10-decisions/decision-index.md` - System shape: `.project-docs/20-architecture/system-overview.md` ## Recall Pointers -- Confirmed report workflow and price formula: `.project-docs/10-decisions/ADR-001-automatic-monthly-trigger-and-total-price-formula.md` -- Business rules and exact field semantics: `.project-docs/40-domain/business-rules.md` -- Evidence-heavy bugs, experiments, investigations: `.project-docs/50-evidence/evidence-index.md` -- Current upload E2E blocker: `.project-docs/50-evidence/topics/2026-07-29-live-synthetic-xml-vertical-slice.md` -- Controlled public deployment snapshot: `.project-docs/50-evidence/topics/2026-07-29-public-deployment-repository.md` -- Workflow lessons and repeated agent mistakes: `.project-docs/60-reflection/reflection-index.md` -- Pending promises, loops, timed follow-ups: `.project-docs/80-commitments/commitments.md` -- Stale or conflicting docs: `.project-docs/90-maintenance/stale-items.md` +- ARR-owned processing decision: `.project-docs/10-decisions/ADR-004-arr-owned-programmatic-processing.md` +- Automatic monthly trigger and formula requirement: `.project-docs/10-decisions/ADR-001-automatic-monthly-trigger-and-total-price-formula.md` +- ARR2.0 implementation evidence: `.project-docs/50-evidence/topics/2026-07-30-arr2-programmatic-pipeline.md` +- Business rules: `.project-docs/40-domain/business-rules.md` +- Open gaps/conflicts: `.project-docs/90-maintenance/stale-items.md` +- Future commitments: `.project-docs/80-commitments/commitments.md` -## Loading Rule - -Keep this file short. Add pointers here, not full history. +Historical Agent/MCP evidence remains indexed in `.project-docs/50-evidence/evidence-index.md` but is not current architecture. diff --git a/.project-docs/10-decisions/ADR-001-automatic-monthly-trigger-and-total-price-formula.md b/.project-docs/10-decisions/ADR-001-automatic-monthly-trigger-and-total-price-formula.md index 6a09a66..c31eb17 100644 --- a/.project-docs/10-decisions/ADR-001-automatic-monthly-trigger-and-total-price-formula.md +++ b/.project-docs/10-decisions/ADR-001-automatic-monthly-trigger-and-total-price-formula.md @@ -1,6 +1,6 @@ # ADR-001: Automatic Monthly Trigger And TOTAL PRICE Formula -- Status: Accepted +- Status: Implemented - Date: 2026-07-29 - Source: User correction in the current session @@ -11,21 +11,26 @@ Earlier discussion incorrectly treated monthly-report generation as a separate u ## Decision 1. The user's workflow begins and ends with uploading the XML. -2. The Agent processes the XML and returns structured data for validation and database persistence. -3. Only after that structured result is successfully committed does the business system automatically trigger the monthly-report processing program. -4. `report_year`, `report_month`, `as_of_date`, or equivalent values may remain as internal execution and audit fields, but the backend must derive them. They are not user inputs in the primary workflow. +2. ARR's frozen processor produces structured data for independent validation and database persistence; the production path has no Agent/MCP dependency. +3. Only after that structured result is successfully committed does the business system emit `arr.daily_version_committed`; a dedicated worker process consumes it and runs the monthly-report program. +4. `report_year`, `report_month`, `as_of_date`, or equivalent values may remain as internal execution and audit fields, but the backend must derive them. The worker uses committed retained `ARRIVAL` facts to select the affected month, and the greatest `ARRIVAL` actually included in the snapshot is the report's `as_of_date`/“更新至”. The XML filename and wall clock are never authorities for this value. 5. The report keeps the `TOTAL PRICE` dimension/field. Each XLSX data-row cell must contain a formula with the business meaning: `TOTAL PRICE = REAL PRICE × NIGHTS × NO_OF_ROOMS` With the current 19-column layout, this is equivalent to `=R2*C2*G2` for row 2 and must fill relatively for later rows. +6. The monthly page automatically discovers newly published versions. A user must not need a separate refresh action + after the worker finishes. ## Consequences - A manual month/as-of form or endpoint is not the primary product flow and must not be presented as a required user step. -- The monthly processor may still accept internally supplied month/cutoff arguments from its orchestrator. -- Existing manual-trigger UI/API code and static-cell workbook generation are implementation gaps, not the accepted requirement. -- The exact internal event/outbox/worker mechanism and cutoff derivation policy remain implementation details to design and verify; they must not be delegated to the user. +- The monthly processor may still accept internally supplied month/cutoff arguments through a controlled recovery CLI/API, but the primary page exposes none of them. +- The dedicated worker leases outbox rows, retries transient publication failures, dead-letters exhausted events, and acknowledges success only after both registered artifacts are active/downloadable. +- Durable `reporting` metadata records the snapshot identity, Finance daily-version lineage, channel manifest and local artifact identities without duplicating monthly business rows. +- The implemented workbook writes and reopens one exact row-relative `TOTAL PRICE` formula per data row. +- The portal uses a visibility-aware, non-overlapping list poll while the monthly tab is open and reloads immediately + when the tab becomes active again. A transient background error keeps the last good list and retries automatically. ## Supersedes diff --git a/.project-docs/10-decisions/ADR-002-controlled-agent-delegates-validation-to-system-boundaries.md b/.project-docs/10-decisions/ADR-002-controlled-agent-delegates-validation-to-system-boundaries.md new file mode 100644 index 0000000..29a5ecf --- /dev/null +++ b/.project-docs/10-decisions/ADR-002-controlled-agent-delegates-validation-to-system-boundaries.md @@ -0,0 +1,41 @@ +# ADR-002: Controlled Agent Delegates Validation To System Boundaries + +- Status: Accepted +- Date: 2026-07-29 +- Source: User clarification in the current session + +## Context + +The ARR data-processing Agent is a dedicated Agent that accepts only business-system messages from a fixed, +controlled entrypoint. Earlier Main Prompt revisions repeated contract, attachment, identity, hash and source +checks already enforced by ARR code. A manual SuperAgent chat test also encouraged chat-input rejection logic, +even though that entrypoint is outside the production contract. + +The implementation already has three authoritative boundaries: + +1. `ProcessingRequest` and `OssProcessingMessageBuilder` validate and construct the fixed PROGRAM_INPUT. +2. `arr-opera-daily-ingest` owns deterministic business processing and output validation. +3. ARR MCP validates the grant and payload, independently replays the source XML, and commits in a database + transaction. + +## Decision + +1. Main Prompt trusts PROGRAM_INPUT supplied by the controlled ARR entrypoint and does not repeat field, + attachment, source, identity, hash or Schema validation. +2. The Agent performs only fixed orchestration: one OSS fetch, one Skill run, one result read, at most one MCP + submission, and one final output. +3. Processing failure does not call MCP. MCP `committed` or `already_committed` remains the only successful + submission outcome recognized by the Agent. +4. Manual chat-upload behavior is not a production acceptance criterion. End-to-end testing starts at ARR Web + or the equivalent controlled business-system entrypoint. +5. If the Agent is ever exposed to untrusted/general inputs, validation must be added at the API/gateway boundary + and this decision revisited; prompt prose must not be treated as the security boundary. + +## Consequences + +- The Main Prompt is shorter and avoids redundant LLM work and token/latency overhead. +- Business and security validation remains deterministic in code rather than probabilistic in the Agent. +- The fixed entrypoint and its access controls become an explicit operational prerequisite. +- Direct SuperAgent chat tests may be useful for isolated Skill experiments but cannot prove or invalidate the + production ingestion flow. + diff --git a/.project-docs/10-decisions/ADR-003-public-read-source-url-for-fetch-oss-file.md b/.project-docs/10-decisions/ADR-003-public-read-source-url-for-fetch-oss-file.md new file mode 100644 index 0000000..2c1672e --- /dev/null +++ b/.project-docs/10-decisions/ADR-003-public-read-source-url-for-fetch-oss-file.md @@ -0,0 +1,59 @@ +# ADR-003: Public-read source URL for fetch_oss_file + +## Status + +Accepted + +## Date + +2026-07-30 + +## Context + +Controlled SuperAgent traces showed that `fetch_oss_file` accepts `object_uri` and `filename`. An `oss://` +request failed with `public_endpoint_missing`, while earlier HTTPS attempts reached file-type handling. The user +confirmed that the installed Tool reads a publicly accessible OSS address without a Provider and that the OSS +deployment is `public-read`. + +The previous ARR adapter explicitly wrote every object with object ACL `private`. That overrides a public-read +bucket and would make a generated public URL unusable. At the same time, making every processing output public +would unnecessarily widen exposure. + +## Decision + +1. ARR requires the integration bucket to report bucket ACL `public-read`; anonymous writes remain forbidden. +2. Only `committed/source_xml/source.xml` is written with object ACL `public-read`. Staged objects, generated + reports, result JSON and exchange objects remain explicitly `private`. +3. `arr-opera-daily-program-input-3` includes a required `oss.url` generated by ARR as + `https://{bucket}.oss-{region}.aliyuncs.com/{encoded-object-key}`. It contains no query signature or secret. +4. The Main Prompt passes `oss.url` unchanged as `fetch_oss_file.object_uri` and the canonical attachment name + as `filename`, exactly once. It must not construct `oss://`, sign or substitute another URL. +5. XML extension/MIME acceptance remains a separate platform Tool configuration requirement. + +## Rationale + +ARR owns the bucket, endpoint and immutable object key, so it can generate one deterministic address without +asking the Agent to infer storage semantics. Object-level ACL selection satisfies anonymous source download +while keeping intermediate and output artifacts private. + +## Consequences + +- `fetch_oss_file` needs no OSS Provider, AccessKey or signed URL for this flow. +- A committed source XML is anonymously readable to anyone who has its URL; the URL is not a secret or an + authorization mechanism. Retention, access logging and public-source privacy review remain deployment duties. +- Readiness now rejects a private bucket for this integration. +- Deploying code and Prompt changes does not by itself allow XML; the platform must permit `.xml` and + `application/xml` if it still returns `extension_not_allowed`. + +## Supersedes + +- The private-object/credential-backed Provider assumption in earlier implementation notes and the historical + `oss://` Prompt experiment. + +## Related + +- `arr_processing/source_message.py` +- `arr_storage/aliyun_oss_v2.py` +- `prompts/arr_opera_daily_program_input.schema.json` +- `prompts/arr_opera_daily_main_agent_prompt.md` +- `.project-docs/50-evidence/topics/2026-07-30-superagent-fetch-oss-prompt-experiment.md` diff --git a/.project-docs/10-decisions/ADR-004-arr-owned-programmatic-processing.md b/.project-docs/10-decisions/ADR-004-arr-owned-programmatic-processing.md new file mode 100644 index 0000000..3d0572a --- /dev/null +++ b/.project-docs/10-decisions/ADR-004-arr-owned-programmatic-processing.md @@ -0,0 +1,52 @@ +# ADR-004: ARR owns deterministic processing and terminal ingestion + +## Status + +Accepted + +## Date + +2026-07-30 + +## Context + +The remote Agent successfully fetched the exact XML and ran the deterministic processor, but model-mediated MCP +arguments reduced a valid 135-record result to 20 records. Prompt changes could guide tool use but could not guarantee +lossless transport of a large structured payload. The user authorized a separate ARR2.0 workspace whose production XML +path has no Agent involvement. + +## Decision + +ARR2.0 runs the frozen `process_daily.py` itself after upload. It stores source and output artifacts as private immutable +objects, validates a canonical `DeliveryEnvelope` with the existing independent validator, and commits through +`PostgresIngestionRepository` in one transaction. The upload request waits for a terminal success or failure result. + +The active runtime does not call SuperAgent, expose MCP, issue submission/read grants, use prompts or require a public +source URL. Existing 009/010 database tables remain for historical compatibility but are not used by this path. + +## Rationale + +This keeps deterministic business logic, source/artifact identity checks, independent validation, version activation, +idempotent delivery replay and atomic Finance writes while removing the unreliable model-serialization boundary. + +## Consequences + +- Upload latency now includes processing, validation and database commit. +- Controlled processor failures are persisted through the normal failed-delivery contract; infrastructure failures use + explicit run/attempt terminal transitions and an outbox event. +- Fresh user uploads create fresh jobs and follow existing business-date versioning; replay of the same delivery remains + idempotent. +- All ARR-managed object ACLs are private even when the bucket itself is public-read. +- The original `/Users/chillishark/ARR项目0727` workspace remains unchanged as the ARR1 rollback baseline. + +## Supersedes + +- ADR-002 for the ARR2.0 production XML path only. +- ADR-003 for the ARR2.0 production XML path only. + +## Related + +- `arr_web/programmatic.py` +- `arr_processing/local.py` +- `arr_web/processing_runtime.py` +- `tests/test_arr_programmatic.py` diff --git a/.project-docs/10-decisions/decision-index.md b/.project-docs/10-decisions/decision-index.md index eb59b35..07cd240 100644 --- a/.project-docs/10-decisions/decision-index.md +++ b/.project-docs/10-decisions/decision-index.md @@ -4,21 +4,15 @@ | ID | Decision | Status | Date | Applies To | Detail | |---|---|---|---|---|---| -| ADR-001 | 月报在 Agent 结构化结果成功入库后由业务系统自动触发;`TOTAL PRICE` 为 Excel 公式字段 | Accepted | 2026-07-29 | 上传链路、月报触发、XLSX 输出 | [ADR-001](ADR-001-automatic-monthly-trigger-and-total-price-formula.md) | +| ADR-004 | ARR2.0 owns deterministic processing, artifact validation and terminal ingestion; no Agent/MCP in production XML flow | Accepted | 2026-07-30 | Upload, processing runtime, OSS ACL, validation, deployment | [ADR-004](ADR-004-arr-owned-programmatic-processing.md) | +| ADR-001 | 月报由独立 worker 自动触发;“更新至”取最新纳入的 `ARRIVAL`;发布后页面自动新增;`TOTAL PRICE` 为公式字段 | Implemented | 2026-07-29 | 月报触发、持久化、页面同步、XLSX 输出 | [ADR-001](ADR-001-automatic-monthly-trigger-and-total-price-formula.md) | -## Superseded Decisions +## Superseded For ARR2.0 | ID | Decision | Superseded By | Date | |---|---|---|---| -| Conversation draft | 用户点击月报按钮并提交或选择年月/截止范围;金额写静态值 | ADR-001 | 2026-07-29 | +| ADR-003 | Public-read source URL passed to `fetch_oss_file` | ADR-004 | 2026-07-30 | +| ADR-002 | Controlled Agent orchestrates Skill and MCP submission | ADR-004 | 2026-07-30 | +| Conversation draft | Manual monthly parameters and static total values | ADR-001 | 2026-07-29 | -## Decision Criteria - -Create or update an ADR when a choice affects: - -- project positioning -- architecture boundaries -- public behavior -- data model -- long-term maintenance -- user-facing workflow +ADR-002/003 remain factual history for the separate ARR1 rollback workspace; they are not ARR2.0 runtime decisions. diff --git a/.project-docs/20-architecture/data-flow.md b/.project-docs/20-architecture/data-flow.md index 915086b..c44bc29 100644 --- a/.project-docs/20-architecture/data-flow.md +++ b/.project-docs/20-architecture/data-flow.md @@ -1,24 +1,61 @@ # Data Flow -## Primary Flows +## Web Access Boundary -| Flow | Source | Destination | Notes | +1. An anonymous browser requesting `/` or `/h5` is redirected to `/login` with an allowlisted return target; anonymous API/download requests receive JSON `401 AUTH_REQUIRED`. +2. `POST /api/login` accepts strict same-origin JSON, verifies runtime-owned credentials under a bounded per-client attempt ledger and issues a random server-side session plus CSRF token in an `HttpOnly`, `SameSite=Strict` cookie. +3. Every portal page, detailed health response, read API, upload/mutation, task trace and artifact download validates the authenticated session. Mutations additionally validate the session CSRF token. +4. `POST /api/logout` validates CSRF, revokes the server-side session and expires the cookie. `/healthz` remains a no-detail anonymous readiness signal for container orchestration. + +## Primary XML Flow + +| Step | Source | Destination | Gate | |---|---|---|---| -| XML ingestion | User upload | Agent processing | Upload is the user's only required action for this flow. | -| Structured result commit | Agent output | PostgreSQL Finance facts | Independent validation/replay must succeed before commit. | -| Automatic monthly trigger | Successful database commit | Monthly-report worker/program | Month and cutoff values are derived internally, not supplied by the user. | -| Monthly workbook build | Committed Finance facts | XLSX artifact | `TOTAL PRICE` cells use `REAL PRICE × NIGHTS × NO_OF_ROOMS` formulas. | +| 1 | Browser XML upload | Uploaded-basename task provenance + private canonical source object + queued DB job | Filename/content/size and immutable object identity | +| 2 | Committed source object | Isolated processor input | Materialization rechecks stored bytes | +| 3 | Fixed `process_daily.py` | Daily/result/structured or failure artifacts | Timeout, confined paths, exit/JSON agreement | +| 4 | Local artifacts | Private committed OSS objects | Role, MIME, size and SHA-256 | +| 5 | Canonical `DeliveryEnvelope` | `DeliveryValidator` | Strict Schema, reconciliation, source/artifact hashes and independent validation | +| 6 | Verified delivery | PostgreSQL Finance facts | Serializable atomic commit/version activation | +| 7 | Accepted/failed run | Task trace and outbox | Persisted state is authoritative | +| 8 | `arr.daily_version_committed` | Dedicated monthly worker | Lease, `SKIP LOCKED`, bounded retry/dead-letter | +| 9 | Retained Finance `ARRIVAL` facts | Monthly snapshot request | Affected month from event version; “更新至” is max included `ARRIVAL` | +| 10 | Monthly snapshot | XLSX/result + `reporting` metadata | Formula reopen-validation, current-pin recheck, atomic activation | +| 11 | Registered active report | Web list/download + outbox acknowledgement | Real report ID; visible monthly tab polls automatically; path/size/SHA-256 recheck before download | ## State Ownership -- PostgreSQL owns accepted/current Finance facts and processing state. -- Generated XLSX files are artifacts derived from a pinned database snapshot; they are not an alternative fact source. +- PostgreSQL owns job/attempt/delivery state, Finance facts, current version and outbox. +- PostgreSQL `reporting` owns monthly publication versions, daily-version lineage, channel manifest and artifact identities; it does not duplicate monthly business rows. +- OSS owns immutable source/output bytes; ARR stores only controlled object identities in the database. +- `processing_runs.uploaded_filename` owns the user-facing browser basename; the source artifact remains canonically + named `source.xml` for processing and validation. +- Temporary processor/validator paths are deleted after each request. +- Monthly XLSX/result bytes live in immutable local archive paths on the current shared output volume; generated report artifacts are derived outputs, not an alternative business fact source. -## External Interfaces +## Booking Source And Company-Report Flow -- User-facing: XML upload, processing status, and controlled report download. -- Internal: Agent structured-result submission and automatic report-task dispatch after commit. +| Step | Source | Destination | Gate | +|---|---|---|---| +| 1 | Authenticated Booking XLSX upload | Bounded parser 2.0 | Requires Tour Code + exact `โรงแรม`, latest-row/cancellation semantics and room-item extraction/review | +| 2 | Parsed raw items | Private artifact + extraction draft | Live migration 015 preserves source coordinates, confirmed/pending/deleted item state and extracted values | +| 3 | Human review | Draft item updates | Confirm/edit or atomically soft-delete 1-50 unique selected items while canonical current Booking source remains unchanged | +| 4 | Fully reviewed draft | Accepted canonical batch + `booking.current_source_batch` | Live 014/015 plus repository zero-pending/atomic activation; real PostgreSQL transaction-only vertical slice passes | +| 5 | Current Finance facts | Company-report stay segments | Finance supplies company/channel/dates/pricing; normalized Group Code is the Booking lookup key; open draft blocks new job | +| 6 | Current Booking room summary | `Booking Room` output | Sum by Group Code + room type; missing or unmatched Group Code leaves Booking Room blank | + +Migrations 014/015 are formally applied: batch 1 is the current source and the draft tables are empty. The source +contains a PostgreSQL draft repository, backend routes, browser editor and GET/POST/PATCH/DELETE transport coverage. +A real-PostgreSQL transaction-only probe created a draft, confirmed its pending item, activated immutable facts and then +rolled everything back, preserving the current source and leaving no synthetic residue. First activation of an actual +operator workbook remains a controlled business action. + +## Monthly Derivation Boundary + +The worker never infers a date from an XML filename or the current clock. It treats the event's `daily_version_id` only +as a lookup key, derives scope from retained Finance `ARRIVAL`, and verifies that the generated report's maximum arrival +equals its persisted `as_of_date`. Every data-row `TOTAL PRICE` cell is `=R[row]*C[row]*G[row]`. ## Last Updated -2026-07-29 +2026-07-31 diff --git a/.project-docs/20-architecture/module-map.md b/.project-docs/20-architecture/module-map.md index 17258cc..bc3bdd8 100644 --- a/.project-docs/20-architecture/module-map.md +++ b/.project-docs/20-architecture/module-map.md @@ -1,27 +1,37 @@ # Module Map -## Source Layout - -| Path | Responsibility | Owner Notes | +| Path | Responsibility | Notes | |---|---|---| -| `arr_web/` | Upload portal and business API | Manual monthly request code conflicts with ADR-001. | -| `arr_mcp/` | Direct Agent-result submission and ingestion boundary | Post-commit trigger should originate at or after this boundary, not before it. | -| `arr_ingestion/`, `arr_processing/` | Validate and persist structured processing results | Keep deterministic replay and transaction semantics. | -| `monthly_reports/` | Database-backed monthly workbook generation | Needs formula-cell output and automatic orchestration integration. | -| `database/` | Finance facts, views, guards and migrations | Database total remains useful for integrity checks. | -| `Dockerfile`, `compose.yaml` | Reproducible single-server Web/MCP runtime | Dockerfile default Web CMD and Compose explicitly enable XML processing; direct source CLI defaults and runtime readiness remain fail-closed. | -| `deploy/` | Caddy TLS/auth boundary, production env template and operator runbook | Only Caddy publishes host ports; never commit the real production env file. | -| `tests/` | Regression and acceptance coverage | Add post-commit trigger and formula-cell assertions when implementation is authorized. | +| `arr_web/auth.py` | Login credential verification and bounded attempt ledger | Environment-owned single-operator identity; dependency-free constant-time comparison; no secret logging | +| `arr_web/programmatic.py` | Upload-to-terminal coordinator | Registers the validated browser basename separately from canonical `source.xml`; no remote/model transport | +| `arr_web/booking_uploads.py`, `booking_ingestion/excel.py` | Authenticated Booking source upload and bounded raw XLSX parsing | Parser 2.0 reads Tour Code plus exact `โรงแรม`, latest-row cancellations and extracted room items with review state; coordinator/backend route contracts target drafts | +| `booking_ingestion/excel_postgres.py` | Earlier direct Booking full-source import/activation path | Its one-item immediate-accept implementation does not represent parser 2.0 review and must not be treated as the completed review repository | +| `booking_ingestion/excel_review.py`, `booking_ingestion/excel_review_postgres.py` | Editable extraction drafts and atomic reviewed-source activation | Single-operator repository uses advisory locking and a zero-pending gate; real PostgreSQL transaction/rollback acceptance passes; actor/reason/revision history is not implemented | +| `arr_web/processing_runtime.py` | PostgreSQL + OSS + processor composition | Active production processing composition | +| `arr_processing/local.py` | Bounded subprocess and output manifest extraction | Never exposes stdout/stderr | +| `arr_processing/policy.py` | Frozen processor/rule identity | Neutral, reusable loader | +| `arr_ingestion/validation.py` | Strict artifact/result validation | Runs `validate_daily.py` on success | +| `arr_ingestion/postgres.py` | Atomic Finance commit and lifecycle state | Four retries only for transient SQLSTATEs | +| `arr_storage/aliyun_oss_v2.py` | Encrypted/unversioned OSS adapter | Writes all objects private | +| `arr_web/downloads.py` | OSS daily + controlled local report download routing | Rechecks metadata, size and SHA-256 | +| `arr_web/job_trace.py` | Programmatic persisted-fact trace | No external trace store | +| `arr_web/app.py`, `arr_web/repository.py`, `arr_web/company_jobs.py` | Authenticated portal routes and paged history reads | Default-deny login gate protects pages/APIs/downloads; daily/monthly counts and rows share a repeatable-read snapshot; company totals/slices share one lock | +| `arr_web/server.py` | Standard-library HTTP transport | Dispatches GET/POST/PATCH/DELETE with one bounded body reader; real socket tests cover review update/delete and missing/oversized lengths | +| `arr_web/static/login.html`, `login.css`, `login.js` | Responsive ARR login gateway | Labeled form, password visibility, generic inline failures, safe desktop/H5 return target and reduced-motion support | +| `arr_web/static/app.js`, `arr_web/static/h5.js` | Authenticated client state, rendering and polling | Desktop includes 50-row Booking draft review/edit, the draft's validated uploaded filename below the review title, individual/all-visible selection, count-aware in-page delete confirmation and activation; company generation keeps the fixed five-company context beside the page title, a four-card upload/period setup row, compact upper-right month control, cumulative CO display labels on the three period cards, short centered period actions and an in-page generation confirmation dialog; session-expiry redirect and CSRF logout remain shared; monthly versions auto-refresh every four seconds | +| `monthly_reports/worker.py` | Dedicated outbox consumer | Lease/reclaim, retry/dead-letter, success acknowledgement after activation | +| `monthly_reports/repository.py` | Monthly snapshot and publication repository | Derives scope from `ARRIVAL`; persists metadata/lineage/artifact identities | +| `monthly_reports/xlsx/build_workbook.mjs` | Monthly XLSX builder and reopen validator | Exact row-relative `TOTAL PRICE` formulas only in column S | +| `monthly_reports/`, `company_reports/`, `channel_analytics/` | Downstream reports/BI | Consume accepted Finance facts | +| `database/012_monthly_report_publication.sql` | Additive metadata-only publication schema | Applied after immutable 008–011 baseline | +| `database/014_booking_current_source_batch.sql` | Booking full-workbook current-source pointer and view scoping | Formally applied on 2026-07-31; batch 1 remains selected | +| `database/015_booking_excel_review_drafts.sql` | Item-level Booking extraction draft state | Formally applied and empty; basic latest-state review only, with no actor/reason/revision history and no DB-enforced zero-pending activation | +| `compose.yaml`, `deploy/` | Web-login + Caddy-HTTPS template and worker deployment boundary | Requires Web credentials; only `/healthz` is anonymously probed; no MCP port/domain/service | +| `tests/test_arr_programmatic.py` | Real success/failure vertical slices | Primary ARR2.0 acceptance proof | -## Dependency Direction - -- Upload/Agent/ingestion writes validated facts; report modules depend on committed facts and must not call back into XML parsing. -- Public traffic enters through Caddy; Web and MCP stay on the internal Compose network and use separate browser/MCP authentication boundaries. - -## Risky Or Sensitive Areas - -- Commit-to-trigger atomicity/idempotency, monthly snapshot selection, XLSX formula preservation, and PII-safe result metadata. +Historical ARR1 compatibility modules and migrations remain for audit/tests but are not imported, installed or deployed +by the active ARR2.0 entrypoint. ## Last Updated -2026-07-29 +2026-07-31 diff --git a/.project-docs/20-architecture/system-overview.md b/.project-docs/20-architecture/system-overview.md index 473d1b7..608cb28 100644 --- a/.project-docs/20-architecture/system-overview.md +++ b/.project-docs/20-architecture/system-overview.md @@ -2,32 +2,52 @@ ## Current Architecture -The ARR system accepts an Opera XML upload, stores the private source artifact, invokes an Agent for deterministic processing, validates the returned structured result, and commits accepted Finance facts to PostgreSQL. Downstream analytics and report programs consume those committed facts. The accepted target flow automatically triggers monthly processing after the commit; the current manual monthly UI/API remains a known implementation gap. +ARR2.0 accepts Opera XML plus a separate Booking Tour Code/`โรงแรม` workbook and owns both deterministic processing +lifecycles. It writes immutable private +objects, runs the frozen processor locally, independently validates the complete artifacts, and atomically commits +accepted facts to PostgreSQL. Downstream analytics and report programs read committed facts. +Successful daily commits also create a durable outbox event. A separate worker derives the affected month and watermark +from committed `ARRIVAL` facts, publishes a validated workbook, and records metadata-only report state in PostgreSQL. ## Main Components -| Component | Responsibility | Notes | -|---|---|---| -| `arr_web` | XML upload, job status, controlled downloads and business APIs | The existing manual monthly-generation request is not the accepted primary flow. | -| SuperAgent + deterministic processing skill | Convert XML to the approved structured result | Must not decide user-facing report parameters. | -| `arr_mcp` / ingestion service | Independently validate/replay and atomically commit accepted structured results | Automatic downstream trigger begins only after commit success. | -| PostgreSQL Finance facts | Authoritative current and retained daily facts | Source of truth for downstream reports. | -| `monthly_reports` | Generate monthly XLSX from committed database facts | Internal month/cutoff inputs are allowed; `TOTAL PRICE` must be an XLSX formula. | -| `channel_analytics` / `company_reports` | Additional read/report consumers | Must not change the monthly-report trigger contract. | -| Caddy + Compose deployment boundary | Terminate HTTPS, authenticate Web users, keep Web/MCP ports internal and start the explicit processing profile | MCP still performs its own bearer check and DNS-rebinding Host validation. | +| Component | Responsibility | +|---|---| +| `arr_web.auth` / authenticated `SessionLedger` | Environment-owned operator credentials, bounded login attempts, random server-side sessions, CSRF and logout revocation | +| `arr_web.programmatic` | Terminal upload orchestration and safe public error mapping | +| `arr_processing.local` / `arr_processing.policy` | Isolated frozen processor execution and approved version/rule identity | +| `arr_storage` | Immutable staged/committed OSS workflow; all object ACLs private | +| `arr_ingestion.validation` | Strict delivery/result/structured validation plus independent daily validation | +| `arr_ingestion.postgres` | Job lifecycle, artifact registration, atomic Finance version commit and outbox | +| `booking_ingestion.excel` | Bounded Tour Code/`โรงแรม` extraction, latest-row/cancellation semantics and room-item normalization | +| `booking_ingestion.excel_review_postgres` | Durable editable draft state and zero-pending atomic current-source activation | +| `arr_web.booking_uploads` | Private Booking workbook upload, draft review orchestration and privacy-minimized source summaries | +| `arr_web.job_trace` | Read-only programmatic trace from persisted run/attempt/delivery/version/outbox facts | +| `monthly_reports.worker` | Dedicated leased outbox consumer with retry/dead-letter behavior | +| `monthly_reports.repository` / `reporting` schema | ARRIVAL-derived snapshots, report versions, lineage, manifests and artifact identities | +| `monthly_reports`, `channel_analytics`, `company_reports` | Deterministic downstream consumers of committed facts | +| Web + Caddy Compose | Web-owned login/session boundary behind Caddy HTTPS; no browser-native Basic Auth and no MCP service | ## Important Boundaries -- XML processing and Finance fact validation happen before the monthly-report trigger. -- The user-facing upload workflow does not collect monthly report year, month, or cutoff date. -- Monthly processing reads validated database facts rather than repeating Agent business logic. -- Database `total_price` may support validation, but the exported `TOTAL PRICE` cell must remain a formula. -- Container deployment enables XML processing through both the Dockerfile default Web CMD and the explicit Compose command, and still requires `processing_ready=true`; direct source CLI execution remains default-closed. +- The uploaded source is re-materialized from committed object storage before processing. +- Login documents/assets, `POST /api/login` and minimal `/healthz` readiness are the only anonymous Web routes. Portal documents redirect to login; business APIs, detailed health, uploads, traces and downloads return `401` without an authenticated server-side session. +- Operator credentials come only from runtime `ARR_WEB_USERNAME` / `ARR_WEB_PASSWORD`; the application fails closed when either is absent. Authenticated mutations also require the per-session CSRF token. +- Processor exit code and JSON status must agree; output paths are confined to an isolated temporary directory. +- Validation completes before the database transaction; any transaction failure leaves no partial active version. +- Booking uploads create review drafts first. Only an explicit activation with no retained pending items can atomically + create immutable Booking facts and switch `booking.current_source_batch`; an open draft blocks company-report creation. +- Only recognized transient PostgreSQL concurrency errors receive bounded transaction retries. +- PostgreSQL state, not HTTP/console output, is authoritative for success. +- Daily downloads are read from OSS and rechecked; monthly downloads resolve only registered active/superseded local artifacts and recheck path, size and SHA-256. +- Fresh uploads are new jobs/versions; exact delivery replay is idempotent. +- Monthly success is acknowledged only after both artifacts are registered and the publication is active; replay of the same snapshot returns the existing report. ## Related Decisions +- [ADR-004](../10-decisions/ADR-004-arr-owned-programmatic-processing.md) - [ADR-001](../10-decisions/ADR-001-automatic-monthly-trigger-and-total-price-formula.md) ## Last Updated -2026-07-29 +2026-07-31 diff --git a/.project-docs/30-worklog/current-state.md b/.project-docs/30-worklog/current-state.md index 796f7fc..e52ee36 100644 --- a/.project-docs/30-worklog/current-state.md +++ b/.project-docs/30-worklog/current-state.md @@ -2,51 +2,309 @@ ## Current Focus -The controlled public-deployment source snapshot is published on `main` at `https://git.nianxx.cn/shiyuyun/wyndham-ARR.git`; initial deployment commit `a701de9` contains the verified source snapshot. The Dockerfile default Web CMD and Compose Web command now both explicitly enable `--enable-processing`; Compose additionally uses `--secure-cookies`, while Caddy remains the documented public listener and MCP retains its independent bearer and exact Host allowlist. Direct source CLI execution remains fail-closed. The current suite passes 260 tests with 2 environment skips plus deployment-entry static assertions. No public-server runtime has been verified from this workspace. +ARR2.0 owns the deterministic XML-to-Finance path and the complete post-commit monthly publication path. The user +uploads XML once. After an accepted Finance commit, a dedicated worker consumes the durable outbox event, derives the +month and “更新至” watermark from committed `ARRIVAL` facts, publishes a validated workbook, and records a durable +download identity. -## Recently Completed +The company-channel detail generator now separates generation permission from calendar completeness: a current-month +or historical C/O period can be generated from the current committed Finance snapshot, while future report months stay +blocked. A not-yet-ended period keeps its fixed C/O cutoff and must be rerun after later source facts arrive if the final +workbook needs those facts. -- 2026-07-29: Fixed the direct Docker-image launch path so its default Web CMD explicitly includes `--enable-processing`, matching Compose, and published code commit `ad3d987`. The controlled local launchd script was also updated and restarted: PID 50730 reports both database and processing ready, and a browser DOM check confirms the XML chooser is enabled. Added a regression test that opens only XML processing mutations; all 260 tests pass with 2 artifact-tool skips. Source CLI defaults and the runtime readiness gate remain fail-closed. -- 2026-07-29: Published the deployment-ready snapshot to the empty self-hosted repository as `main`, established upstream tracking and verified that the remote branch contains initial commit `a701de9`. -- 2026-07-29: Added a Linux Dockerfile, Compose, Caddy TLS boundary, production env example and deployment runbook. The Web health gate requires both `database_ready` and `processing_ready`; the production command opens XML processing without changing the source default. -- 2026-07-29: Added opt-in HTTPS `Secure` session cookies, made the booking fixture root portable, removed local paths/test endpoints/temporary tunnel names from the publishable snapshot, and verified 271 final candidate files (about 2.0 MiB) contain no detected secrets, symlinks or files larger than 1 MiB. -- 2026-07-29: Ran the complete Python suite in a dependency-complete Python 3.12 environment: 259 tests passed and 2 were skipped. Compose YAML, JSON contracts and controlled package checksums also passed static validation. -- 2026-07-29: Restarted the controlled MCP and fixed-domain ngrok sessions. An initial authenticated 421 exposed a preserved public Host; the MCP was safely restarted with an exact loopback/public Host allowlist, after which public initialize and tools-list both returned 200. -- 2026-07-29: Reverified gateway 1.0.1, protocol 2025-11-25, one tool, 2195-byte advertised schema, and zero `$ref`/`$defs`/`$id` keys without invoking the business tool. -- 2026-07-29: Diagnosed the SuperAgent MCP `failed` state: ports 8890/4040 have no listeners, the old temporary PIDs are gone, TLS remains valid, and the public `/mcp` request is rejected by ngrok as an offline endpoint before reaching ARR authentication or MCP protocol handling. -- 2026-07-29: Started a controlled upload-capable Web instance on `127.0.0.1:8766` and submitted one no-PII XML through the real page. -- 2026-07-29: Confirmed job `arrjob-fdc2c1a0695c41b3b372a52033b2f567` reached SuperAgent run `7e1ad2fe-cb59-4d04-b92c-9f09d98f4ef8`; remote status was `success`, but MCP submission and all `2038-01-19` Finance facts remained absent. -- 2026-07-29: Safely terminalized the failed test as `PROCESSING_RESULT_MISSING`, revoked its unconsumed grant, verified zero business writes, and stopped port 8766. -- 2026-07-29: Confirmed that XML upload is the only required user action; successful structured-data commit must automatically trigger monthly processing. -- 2026-07-29: Confirmed that `TOTAL PRICE` is required and must be an Excel formula equal to `REAL PRICE × NIGHTS × NO_OF_ROOMS`. -- 2026-07-29: Initialized `.project-docs` and recorded the correction in ADR-001. +## Completed On 2026-07-31 -## In Progress +- Simplified the three company-channel generation cards by removing the visible `第一期`/`第二期`/`第三期` + labels and making the C/O ranges the primary card values: `C/O:01-10`, `C/O:11-20` and a month-aware final range + (`C/O:21-30` for 30-day months or `C/O:21-31` for 31-day months). + Completion-state chips (`周期未结束` / `周期已结束`) and Bangkok completion times remain, while the underlying period + keys and generation payload are unchanged. An incomplete period is now still generatable; future months remain blocked. + JavaScript/HTML checks, 28 focused Web/company tests, 348-test discovery and isolated 1280x720/390x844 browser QA + pass with no overflow or page-console warnings. The shared port-8766 service was restarted at 14:36:45 +08 and + its `/healthz` endpoint returns `ready`. +- Simplified company report results for end users: duplicate review problems are collapsed by type/code/period so a + repeated `同房型存在不同静态价格,需人工复核` item appears once per company and period. Replaced the internal + `版本`/`version_no` display with `生成时间`, using the completed job time and falling back to its created time; + API version metadata remains intact. JavaScript/HTML checks, 28 focused Web/company tests and targeted deduplication + execution pass. +- Rebuilt the company-channel report setup as a dense four-card desktop row: `上传 Excel 报表` followed by the first, + second and third C/O periods. The `报表月份` picker is a compact visually unlabeled control in the large setup + card's upper-right corner, and all period CTAs now read `生成` with responsive card-sized widths. Replaced the + browser-native generation confirm with an in-page accessible dialog showing month/period context; cancel, backdrop + and Escape dismiss without submitting, while explicit confirmation preserves the existing payload, generation polling + and business behavior. JavaScript/HTML checks, 28 focused Web/company tests, 348-test discovery and isolated + 1280x720/390x844 browser QA pass with no overflow or console warnings; the shared port-8766 service was restarted at + 14:36:45 +08 and its `/healthz` endpoint returns `ready`. +- Rebalanced the company-channel setup surface for the current Finance workflow. The fixed five-company scope now sits + beside `公司渠道明细` as pale helper text, the Excel upload action is bounded to a 760px desktop rail and remains + full-width/single-column on mobile, and `刷新任务` exposes a read-only scope tooltip. `报表月份` remains the C/O + report-month selector because it drives period completeness state, history filtering and the generation payload; no report, + upload or database behavior changed. JavaScript/HTML checks, 28 focused Web/company tests, 348-test discovery and + isolated 1280x720/390x844 browser QA pass with no overflow or console warnings. Port 8766 was not mutated by QA. +- Compressed the daily overview row to a compact equal-height desktop treatment. The upload card now uses a horizontal + icon-and-copy dropzone, reduced heading/footer spacing and smaller controls, while the three KPI cards stretch to the + same row height. Mobile upload layout remains unchanged. JavaScript syntax and 20 focused Web/static/trace tests pass; + no upload, API or processing behavior changed. +- Refined the company-channel generation controls for dense Finance use. The three period CTAs are now compact + 104–124px actions centered inside each card, while `提取并核对` uses the same 42px button family and is centered + inside the upload action column on desktop and mobile. Labels remain single-line, upload/report behavior is + unchanged, 28 focused Web/company tests and the 348-test discovery pass, and the restarted port-8766 service is + healthy at 13:37:49 +08. +- Restored equal card heights across the daily overview row. The ARR.XML upload panel and the three KPI cards now + stretch to the same grid-row height, with KPI content centered inside each card; mobile stacking remains unchanged. + JavaScript syntax and 20 focused Web/static/trace tests pass; no upload, API or processing behavior changed. +- Reverted the KPI width shrink after clarification. The three daily cards now retain their original grid-column widths, + while their height is content-sized and vertically centered in the upload module's shared desktop row. JavaScript + syntax and 20 focused Web/static/trace tests pass; no upload, API or processing behavior changed. +- Centered the shortened daily KPI cards within the upload module's shared desktop grid row, preserving their widths, + 270px height and mobile content-sized fallback. JavaScript syntax and 20 focused Web/static/trace tests pass; no + upload, API or processing behavior changed. +- Tightened the three daily KPI cards so they no longer inherit the upload panel's full grid-row height. The desktop + cards keep their existing widths and data hierarchy, use a compact 270px height with smaller decorative rings, and + return to content-sized cards on narrow screens. JavaScript syntax and 20 focused Web/static/trace tests pass; no + upload, API or processing behavior changed. +- Recomposed the daily overview into one desktop row containing the ARR.XML upload station plus the ARRIVAL DATE, + processing-duration and NO. OF ROOM cards. Removed the duplicate top `Daily Report` heading and `THIS MONTH` eyebrow, + renamed the daily-history heading to `Daily Report`, and kept the upload request, inline progress estimate and + on-demand task-log behavior unchanged. JavaScript syntax and 20 focused Web/static/trace tests pass; no API or + processing behavior changed. +- Updated the daily XML upload surface so submitting ARR.XML stays on the daily page instead of opening the task-log + dialog automatically. The upload card now shows a compact stage-based estimate for upload, fixed processing, + validation and database commit, finalizing at 100% on success and retaining an inline error state on failure. The + header task-log button and daily-history row actions still open the existing trace dialog on demand. JavaScript + syntax and 15 focused Web/static/trace tests pass; no API or processing behavior changed. +- Simplified the desktop monthly page into a single six-column download table. The redundant standard-monthly heading, + `VERSION HISTORY`/version-record copy, visible monthly `版本` column and C/O-period footer note are removed; the + navigation label is now `月报`; the list is identified by a compact `月报处理` heading. The visible `更新至` cell reads the explicit `max_arrival_date` API field, which is + backed by the published run's `as_of_date` (the maximum included `ARRIVAL`) while retaining the legacy field and + version ordering for compatibility. The remaining panel uses a compact live-status toolbar and responsive table + spacing; focused Web/schema/task-log tests and JavaScript/Python syntax checks pass. No report or business data changed. +- Froze the Markdown-backed July company-channel baseline before any Booking Excel activation. The read-only, + non-publishing package binds the exact `RES_COMMENT_TYPE_OF_ROOM.md` hash to accepted Booking batch 1, five pinned + Finance daily versions, processor 1.2.0/rule identity and five generated XLSX files. All 314 output rows independently + match the Markdown-derived Booking Room expectation; 27 are filled and 287 are correctly blank. The builder reopened + and exact-value checked every workbook, rendered all 15 sheets, and the retained SHA-256 inventory verifies every + package file. One reviewing Excel draft remained separate; the current Booking pointer and room-item counts were + unchanged. +- Refined the Booking record-extraction review surface for dense operator work. The page now renders 50 records at a + time, supports individual and all-visible selection, and deletes 1-50 unique selected records through one bounded + atomic backend operation. Single and batch delete share a count-aware in-page dialog; cancel, backdrop and explicit + Escape handling never submit and restore focus. The separate accepted-source status/summary is removed; the open + review now identifies its own extraction records with the validated uploaded workbook filename directly below + `Booking记录提取`, wrapping fully on narrow screens. The requested review/upload helper copy is removed. Isolated authenticated browser QA covered one/two/50-row + selection plus 390×844 layout without calling delete; the full suite passes 346 tests with 10 expected optional skips. +- Implemented the deterministic Booking Excel extraction program requested for raw Tour Code/`โรงแรม` workbooks. + It applies physically-last-row replacement/cancellation, splits bracketed room items, normalizes agreed TWN/DBL + variants, preserves quantities on unknown labels, and routes unknown/unbracketed room names to editable/deletable + pending records. The supplied workbook replays to 26 Tour Codes, 37 items and 7 pending items; `LLT260715AC` becomes + `TWN/12 + DBL/9`, while `LT260715LD` becomes `U-TWN/17 + pending/2`. Durable review/activation, paged desktop UI and + bounded GET/POST/PATCH/DELETE transport are present. A real-PostgreSQL transaction probe created a two-item draft, + corrected its pending item, atomically activated one three-room source row and then rolled the outer transaction back; + source batch 1, zero drafts and zero synthetic artifacts were restored. No persistent Booking/report business write + was performed. +- Confirmed end-to-end readiness boundaries. The company-channel processor already reads current Booking views at report + generation; a live read-only July snapshot produced valid 5/5 results from 417 Finance facts and 27 matched Booking + Group Codes, with missing/unmatched Booking Room blank. The expanded Booking/company/real-XLSX suite passed 55/55. + The controlled real-PostgreSQL draft/edit/activation path passed inside an outer rollback. July `21-month-end` does not open until + 2026-08-01 00:00 Bangkok, and a shared-database test workbook must be a deliberate complete replacement. +- Audited Booking parser 2.0's human-review requirements across the live schema, current source and active Web process. + During the audit, concurrent work applied migrations 014/015: the live database now has a current-source pointer plus + empty draft/item tables supporting basic confirmed/pending/deleted latest-state editing. It still lacks actor, reason + and revision history. Its earlier GET/POST-only transport finding was subsequently repaired with PATCH/DELETE handlers + and real socket coverage; real PostgreSQL write/rollback acceptance now passes. The audit itself made no persistent + database/runtime/business-data mutation. +- Completed a privacy-minimized, read-only Booking dimension/database audit. `Group Code + room type + quantity` is the + sufficient normalized allocation model for current data, whose nonblank codes do not cross stay/company/channel; + missing/unmatched enrichment is blank as designed. Its initial parser/importer snapshot was superseded by the dedicated + manual-review audit above; migrations 014/015 are formally applied and the transaction boundary is verified, while the + first operator-authorized real workbook activation remains incomplete. + No business data changed by this audit. +- Reduced the ARR login gateway to one clear brand statement and one form. The old workflow marketing copy, numbered + steps, repeated welcome/access copy and help/footer disclaimers are removed; the remaining field labels are + `username` and `password`. Existing password visibility, loading/error, safe-return and authentication behavior is + unchanged. Thirty-three focused Web/auth/UI tests pass, and live 390x844 plus 1280x720 checks show no horizontal + overflow or browser warnings/errors. -- No local repository publication work remains. Runtime deployment, DNS/Secret injection and SuperAgent stable-domain rebinding belong to the user's public server environment. +## Completed On 2026-07-30 -## Next Recommended Steps +- Added application-owned Finance login across desktop, H5, APIs, uploads, traces and downloads. Runtime credentials are + required, login attempts are bounded, random server-side sessions retain hardened cookies/CSRF, logout revokes the + session, and only login assets plus no-detail `/healthz` remain anonymous. Caddy now owns HTTPS only. +- Added a responsive ARR login gateway and desktop/H5 logout/session-expiry behavior. The live source passed wrong and + correct login, password visibility, safe `/`/`/h5` return, both logout paths, zero-console-error checks and + 375/768/1024/1440 no-overflow verification. +- Added and applied additive migration 013. New uploads store the validated browser XML basename on + `ingestion.processing_runs`, while private source artifacts and processor input remain canonical `source.xml`. + Daily history and task trace read only the new provenance; pre-013 rows render `—`. +- Added and applied additive migration 012. The `reporting` schema stores monthly versions, Finance daily-version + lineage, channel row-count manifests and two artifact references without duplicating business/guest rows. +- Replaced in-memory monthly identities with database IDs, sequential versions, canonical snapshot hashes, atomic + activation/supersession, persisted failures and idempotent published-snapshot replay. +- Made the report's `as_of_date` equal the greatest `ARRIVAL` actually included in its snapshot. XML filenames and the + wall clock are ignored. +- Added a standalone `monthly_reports.worker` process with leases, `FOR UPDATE SKIP LOCKED`, expired-lease reclaim, + bounded retry/backoff and dead-letter behavior. +- Made outbox acknowledgement conditional on a registered active/superseded report and both workbook/result artifacts. +- Replaced the synthetic monthly list and unavailable download resolver with `reporting.monthly_runs` reads and + confined path/size/SHA-256 checked downloads. +- Removed month/cutoff/manual-generation controls from the primary page. The guarded POST route remains only as a + controlled recovery surface when explicitly enabled. +- Changed every XLSX data-row `TOTAL PRICE` cell to the exact row-relative `=R[row]*C[row]*G[row]` formula and validates + the complete formula matrix again after reopening the workbook. +- Removed the monthly refresh control. The visible monthly tab now performs one non-overlapping list read every four + seconds, stops while hidden/inactive, reloads immediately when restored, and preserves the last good list during a + transient background failure. +- The desktop and H5 headers use the plain `ARR Report` identity with no decorative icon. The daily content heading is + `Daily Report`; the duplicated daily/monthly explanatory copy has been removed without changing automatic publishing. +- The desktop header utility formerly labeled `手机看板` is now `任务日志` and opens the existing black task console in + a native modal. The console no longer occupies the daily-processing layout; upload completion and daily-history row + activation open the selected job directly. `/h5` remains available by direct URL. Both history and trace SQL are + explicitly limited to `pipeline_type = 'opera_daily'`, so this is a one-daily-job processing trace rather than a + global server, monthly-run or company-report log. +- Refined the daily desktop surface after visual review. `ARR Report` remains the dominant workspace title while + `Daily Report` is smaller; `任务日志` is a compact outlined button at the adjacent status-label size; the upload + station is centered and responsive; decorative `01 / UPLOAD` and `本月留痕` labels are gone; and `开始处理` is a + compact right-aligned action. No upload, API, route or trace behavior changed. +- Daily, monthly and selected-month company histories use 50 records per page. Each footer shows the exact monthly + total, current visible range, page number and previous/next controls; counts and rows are read from one database + snapshot or coordinator lock. Channel BI remains an aggregate view rather than a paginated detail list. -1. Redeploy the latest repository image using `deploy/README.md`, inject real Secrets only on the server, and require the public Web health response to report `database_ready=true` and `processing_ready=true`; any platform override of Docker CMD must retain `--enable-processing`. -2. Rebind SuperAgent to `https:///mcp`, rediscover exactly `arr_submit_processing_result`, save/publish the new Agent version, and stop relying on the temporary ngrok endpoint. -3. Rerun the vertical slice with a fresh no-PII XML and require a `committed`/`already_committed` receipt plus matching Finance version, current pointer and fact rows. -4. In separately authorized implementation work, add the automatic post-commit monthly dispatch and required `TOTAL PRICE` formula behavior. +## Live Acceptance -## Open Questions / Blockers +- Five `arr.daily_version_committed` events have produced July publications: one is the earlier database acceptance + fixture `mvp-v1-fixture-20260727`; four are real 07-20/07-21/07-22/07-23 uploads. +- Report ID 4/version 4 is active for 2026-07 with `as_of_date=2026-07-27`, 417 rows and six channels; V03/V02/V01 are + superseded. Its persisted date equals max current `ARRIVAL` structurally, but that maximum comes from the fixture. +- The superseded V03 workbook has 308 authorized `TOTAL PRICE` formulas and zero mismatches. Its 40,635 bytes and SHA-256 + `494fb78283f68cb7bffce2501b3531613c494d148a56a7ccc8d2aabe46b1e29c` match the registered artifact. +- Earlier browser/access-log verification showed V03/V02/V01 downloads and repeated monthly API reads at the + four-second cadence. The current API now lists V04 active above those versions; ARR2 Web remains green and the worker + remains separate. +- ARR2 Web is active on all local interfaces at port 8766 under detached Screen session `arr2-web-8766`. The current + trusted-Wi-Fi entry is `http://192.168.3.48:8766/`; anonymous root redirects to login and `/login` plus `/healthz` + return HTTP 200. The current process restarted at 2026-07-31 14:36:45 +08 and loads the Booking bulk-review + composition; the public health check returns `ready`. + Authenticated acceptance reports database, processing, monthly reports, downloads, company reports and company-source + upload ready. At the earlier 11:30 acceptance the draft endpoint returned no open draft; this later UI task did not + authenticate against, inspect or mutate the shared draft contents. Anonymous PATCH/DELETE reach the application auth gate and return JSON + 401 rather than transport-level 501. Migrations 014/015 are live and the current pointer selects batch 1; no persistent + Booking write was performed. Legacy port 8765 remains closed; the mode-0700 launcher keeps secrets outside the repository. +- The active ARR2 analytics API on port 8766 returns 417 rooms because the known one-row fixture remains current. +- Live pagination acceptance on port 8766 currently reports 24 daily jobs, 4 monthly versions and 3 company jobs. All + three render page 1/1 with correct disabled boundary controls; a 390-pixel viewport has no horizontal overflow. +- Migration 013 is live in `booking_test`: 25 total processing runs, zero falsely backfilled upload filenames and a + validated constraint. A rolled-back Unicode-basename probe proved history/trace return the upload name while the + artifact remains `source.xml`. +- The pre-012 privacy-minimized metadata checkpoint is + `runtime/backups/booking_test_pre_012_20260730T151922+0800/manifest.json`, SHA-256 + `230206eb074fd5877132744eb592b3dc6cd6638d12a3dd4d71a8101a9bd2998e`. -- Docker and Caddy are not installed in this workspace, so image build, Compose runtime, DNS, ACME issuance and public HTTP checks must be performed on the deployment server. -- SuperAgent administration requires the user's authenticated platform session; its stale MCP configuration must be replaced with the new stable public MCP domain after deployment. -- The restored ngrok/MCP sessions remain temporary and are not the formal deployment target. -- `ProcessingRunner.poll()` can emit `delivery_missing`, but the PostgreSQL state adapter has no matching status mapping. The live run therefore required a guarded manual terminalization transaction. -- The exact automatic monthly dispatch mechanism and business-time/cutoff derivation policy still require implementation design; neither is a user input. +## Active Data-Quality Incident -## Risky Areas +- The company-report cutoff-10 and cutoff-20 Web jobs both completed 5/5 with valid zero-row workbooks because every + current supported-company fact has C/O in the 21-to-month-end period. Processor 1.2.0 implements the final + user-confirmed fallback: no Group Code leaves both `RES_COMMENT`/`Booking Room` blank; a present Group Code that + cannot resolve in Booking remains visible while only `Booking Room` is blank. A repeatable-read July 31 preview is + now valid 5/5: LianTai 138 rows/122 blank Booking Rooms, QBD 139/128, DY-AI-Easy-KB 1/1, FengRun 33/33 and HanaTour + 3/3. No report or business-data mutation was performed. The active port-8766 runtime now loads the current processor + and reports company-report readiness true; one controlled 5/5 Web job remains a separate acceptance action, with a + post-completion rerun recommended if later source facts should be reflected. +- Finance current version 2 is `synthetic.xml` from provider `local_fixture`; it adds 1 room, 3 room-nights, 5,400 + revenue and a synthetic room type to Channel BI and all July monthly publications. +- V01's legitimate operational source is exactly 119 rows with `ARRIVAL=2026-07-21`; its persisted lineage and + hash-matched workbook also contain the one-row 07-27 fixture, which is why the stored report says 120 rows and + `as_of_date=2026-07-27`. This is contaminated content, not a metadata-only date error. +- Excluding the fixture leaves the verified four-day business total of 416 rows/rooms, 853 room-nights and 1,451,350 + revenue through ARRIVAL 2026-07-23. Hash matching and isolated replay prove the accepted file mapping is + 07-20=100, 07-21=119, 07-22=88 and 07-23=109; the user's total was right but the 07-20/07-22 labels were reversed. +- No database repair was performed during diagnosis. The fixture should be retired from the current projection without + deleting immutable history, followed by a clean July republication and live API verification. +- An already-open Channel BI view does not refresh after upload or subsequent tab entry, so it can remain at the old + 308 value even though the API is 417. This frontend freshness gap is separate from the one-row fixture contamination. +- The BI `公司数` card actually counts worksheet channels; LianTai GROUP/FIT are separate, so six channel keys can still + correspond to five top-level companies. -- A SuperAgent run reporting `success` is not business success; only an MCP receipt and committed database facts qualify. -- Enabling upload on the current all-interface 8765 listener without a confirmed authentication boundary would expose a mutation endpoint. -- Committing `deploy/.env.production`, publishing 8765/8890 directly, omitting TLS/Basic Auth, or reusing the MCP/SuperAgent/HMAC credentials would break the deployment boundary. -- Triggering before commit, duplicate trigger delivery, deriving the wrong reporting period, or replacing formulas with cached/static amounts. +## Verification Status + +- Migration 012 passed transaction-only up/down probes, including a synthetic active publication followed by rollback. +- The 38-test monthly/migration/Web regression set passed, including real XLSX build/reopen checks. +- A clean full-suite rerun with the optional ARR1 compatibility dependency installed passed 291 tests in 108.2 seconds + with 7 explained environment/fixture skips and no failures or errors. +- The automatic-list follow-up passed 18 focused Web/schema tests, JavaScript syntax checking and a clean 291-test full + discovery in 110.8 seconds with the same 7 explained environment/fixture skips. +- The post-update BI diagnosis passed 24 focused analytics/contracts/Web tests and reconciled both live Web APIs against + a repeatable-read, read-only database snapshot. +- Hash-matched local source inspection and isolated deterministic replay reproduced `0720.XML=100` and `0722.XML=88`; + the 07-23 accepted run was traced to its 16:12:27 commit and 109 retained rooms. +- The final scoped company/Booking/report suite ran 32 tests successfully with three expected private-fixture skips; it + included real XLSX export, value verification after reopen, rendering and the dedicated no-Group-Code path. Four Web + company-task coordinator tests also passed. +- The 50-row pagination follow-up passed JavaScript syntax, 29 focused Web/repository tests and the complete 305-test + discovery with 10 existing environment-dependent skips. Live desktop and 390-pixel checks confirmed all three + totals/ranges and boundary-button states. +- The upload-filename change passed 69 focused tests, JavaScript/Python syntax checks and a clean 312-test full + discovery in 99.280 seconds with 10 environment/fixture skips. +- The application-login change passed a 33-test focused auth/Web/company/deployment set after final compatibility edits, + JavaScript/Python syntax checks, Ruby static Compose parsing and the clean 317-test full discovery in 97.311 seconds + with 10 environment/fixture skips. Docker is not installed locally, so live `docker compose config` was not claimed. +- The expanded absent-or-unmatched company-report fallback passed a 37-test + company/Booking/real-XLSX/Web-coordinator suite with three expected private-fixture skips and no failures; the current + controlled July 31 projection previewed valid 5/5. A separate non-publishing real-data vertical slice built, reopened, + value-checked and rendered all five actual workbooks in a temporary directory. +- The daily visual-polish follow-up passed JavaScript syntax and 33 authenticated Web/auth/daily-visual/task-log tests. + Live 375/768/1024/1440 checks confirmed the corrected title hierarchy, centered upload geometry, compact actions, + task-log dialog focus restoration, zero horizontal overflow and zero browser warnings/errors. +- Live login activation passed anonymous root/API rejection, exact credential login, hardened cookie/session/CSRF, + authenticated desktop/H5/history reads, all five readiness flags, logout revocation and loopback/LAN health. Read-only + totals remained 24 daily jobs, 4 monthly versions and 3 company jobs; no upload, report job or worker was started. +- Booking extraction runtime acceptance passed authenticated health/source-draft reads, clean logout, anonymous + PATCH/DELETE application routing and direct read-only repository access. At that probe all relevant readiness flags + were true, source batch 1 remained current and no draft existed. A later operator upload created one reviewing draft; + the Markdown baseline freeze proved it remained isolated and did not change batch 1. A 2026-07-31 14:14 +08 live + read-only recheck found no reviewing draft and still found Markdown batch 1 current with 867 rows, six worksheets and + 348 Group Codes, so the draft blocker is currently clear without any Booking source switch. +- The same live recheck found that the current July company-report projection has advanced beyond the frozen + five-version baseline: 986 supported-company Finance facts now all have C/O dates from July 21 through July 30. + Read-only processor 1.2.0 output remains valid 5/5 with 598 rows, 224 filled Booking Rooms and 374 blanks. The earlier + 314-row package remains correct for its pinned snapshot; it is not the expected row count for this later live + projection. The first two July periods can now be generated from the current snapshot before their calendar + completion; any later Finance facts within the fixed C/O ranges require a rerun. +- Final post-change discovery ran 343 tests in 102.451 seconds: all passed, with 10 explained optional + renderer/private-fixture skips. JavaScript syntax, Python compilation, supplied-workbook replay and the 17 focused + parser/review/coordinator/router/socket tests are also green. +- Booking review persistence passed a real-PostgreSQL transaction-only vertical slice: draft creation exposed two items + with one pending, edit confirmed the pending item, activation produced one source row with quantity three, and the outer + rollback restored batch 1 with no synthetic draft, source or artifact residue. +- Isolated authenticated browser checks covered the upload-first desktop flow, editable review table, fixed-company text + and responsive 375/390-pixel layouts with no horizontal overflow or console errors. + +## Remaining Deployment Work + +- Perform the first operator-authorized real workbook upload/review/activation, then run one controlled five-company + report and verify all five downloads. The database transaction boundary is accepted, but this task intentionally did not + replace the current business Booking source. Because activation replaces the whole source, supply a complete workbook + and restoration plan. +- Decide whether Booking confirmation is a simple single-operator correction loop or an auditable approval workflow; + add actor/reason/revision history and stronger declarative DB transitions if audit-grade confirmation is required. +- Upload one controlled raw Tour Code/`โรงแรม` workbook, review/activate it, confirm + replacement semantics and run one controlled company report. A period may be generated before its Bangkok completion + boundary, but rerun it after completion if the source snapshot changes; the currently open July periods contain zero + rows and cannot prove populated Booking Room. Before real use, confirm whether 23 fixture Group Codes shared by + `DY-AI-Easy-KB` and `LIANTAI-FIT` are intentionally additive; the current view merges them globally. +- The prior 8766 outage and credential gate are resolved. The current Web process is a detached local Screen session, + not a reboot-persistent service; `/Users/chillishark/.local/bin/arr2-web-8766 --check` validates its private route and + Keychain inputs without printing them before a controlled restart. +- Live authentication/readiness is accepted, but no business mutation was authorized for this activation. One no-PII + upload should still confirm migration-013 filename provenance after restart, and one controlled company-report job + should confirm processor 1.2.0 produces all five downloads. +- The operator-selected Web password satisfies the enforced length but should be rotated to a distinct high-entropy + value in Keychain, followed by one controlled Web restart. +- The workstation runs Web and worker as separate processes. The checked-in Compose image intentionally does not claim + to run the worker because it does not package the workstation-only artifact-tool module; a production image/process + manager must supply Node/artifact-tool and the shared output volume before enabling that service. +- A formally controlled no-PII server acceptance run remains appropriate after that deployment packaging is complete. +- ARR2.0 intentionally has no copied Git history; version-control initialization/review remains independent. ## Last Updated -2026-07-29 +2026-07-31 diff --git a/.project-docs/30-worklog/task-history.md b/.project-docs/30-worklog/task-history.md index bcd5060..807234b 100644 --- a/.project-docs/30-worklog/task-history.md +++ b/.project-docs/30-worklog/task-history.md @@ -4,6 +4,62 @@ | Date | Task | Outcome | Docs Updated | |---|---|---|---| +| 2026-07-31 | Allow early company-channel report generation and rename period states | Removed the old time-release gate from company-channel generation. Current-month incomplete periods now generate from the current committed Finance snapshot with the existing fixed C/O cutoff; historical months can rerun, while future months remain blocked. Replaced the permission-oriented state copy with `周期未结束` / `周期已结束` and added a confirmation warning that later data requires rerun. `node --check`, Python compilation, HTML parsing, 28 focused tests and the project-venv full discovery of 348 tests with 10 skips pass; no report or business data was generated | Business rules, current state/history, evidence topic/index, scoped planning record | +| 2026-07-31 | Simplify company-channel period card labels | Removed the visible `第一期`/`第二期`/`第三期` copy and changed the three card headlines to `C/O:01-10`, `C/O:11-20`, plus a month-aware final range (`C/O:21-30` or `C/O:21-31`). Release status and opening time remain visible; internal period keys and generation requests are unchanged. JavaScript/HTML checks, 28 focused tests, 348-test discovery and isolated 1280×720/390×844 browser QA pass with no overflow or page-console warnings | Current state/history, module map, company-period evidence/index | +| 2026-07-31 | Simplify company report result details | Collapsed duplicate review problems by type/code/period; replaced the user-facing `版本` column with `生成时间` from job completion time, with creation-time fallback. Internal `version_no` API data remains unchanged. JavaScript/HTML checks, 28 focused tests and targeted deduplication execution pass | Current state/history, module map, company-period evidence/index | +| 2026-07-31 | Recompose company-channel report cards and replace native generation confirmation | Placed the Excel source and three C/O periods in one responsive desktop card row, moved the compact visually unlabeled month picker to the setup card's upper-right, shortened all period CTAs to `生成`, and replaced the browser-native confirm with an accessible in-page dialog supporting cancel, backdrop and Escape dismissal. Existing generation payload/polling and business behavior remain unchanged. JavaScript/HTML checks, 28 focused tests, 348-test discovery and isolated 1280×720/390×844 QA pass with no overflow or console warnings. Shared port 8766 restarted at 14:26:56 +08 and `/healthz` is `ready` | Current state/history, module map, company-detail evidence/index, scoped planning record | +| 2026-07-31 | Rebalance company-channel title and upload rail | Moved the fixed five-company context beside `公司渠道明细` as pale helper text, removed the duplicate generator scope block, and bounded the Excel upload action to a 760px desktop rail with a full-width mobile fallback. Kept `报表月份` and `刷新任务` behavior intact, adding a read-only refresh tooltip. JavaScript/HTML checks, 28 focused tests, 348-test discovery and isolated 1280x720/390x844 QA pass with no overflow or console warnings | Current state, module map, company-detail evidence/index, scoped planning record | +| 2026-07-31 | Compress the daily overview cards to one-third height | Reduced the desktop upload card's internal spacing and changed its dropzone to a compact horizontal icon-and-copy layout. The upload card and three KPI cards now share a compact equal grid-row height, while the mobile upload layout remains unchanged. JavaScript syntax and 20 focused Web/static/trace tests pass; no upload/API/processing behavior changed | Current state/history, KPI-card evidence/index | +| 2026-07-31 | Compact company-channel action buttons | Reduced the three generation CTAs to a centered 104–124px width family inside their period cards and made `提取并核对` a matching 42px centered button. Labels remain one line with active/focus states intact; no upload, extraction, report or database behavior changed. JavaScript/HTML checks, 28 focused tests, 348-test discovery and isolated 1280×720/390×844 browser geometry checks pass with no overflow or console warnings. Port 8766 restarted at 13:37:49 +08 | Current state/history, company-action visual evidence, scoped planning record | +| 2026-07-31 | Match daily overview card heights | Restored shared grid-row stretching so the ARR.XML upload panel and ARRIVAL DATE, processing duration and NO. OF ROOM cards have identical desktop heights. KPI content stays vertically centered, mobile stacking is unchanged, and no upload/API/processing behavior changed. JavaScript syntax and 20 focused Web/static/trace tests pass | Current state/history, KPI-card evidence/index | +| 2026-07-31 | Revert KPI width shrink and fit card height to content | Restored the original grid-column widths for ARRIVAL DATE, processing duration and NO. OF ROOM. Their height is now content-sized with the existing shared-row centering, while the upload module and desktop row remain unchanged. JavaScript syntax and 20 focused Web/static/trace tests pass; no upload, API or processing behavior changed | Current state/history, KPI-card evidence/index | +| 2026-07-31 | Fit daily KPI card widths to content | Set ARRIVAL DATE, processing duration and NO. OF ROOM cards to intrinsic content width, centered within their existing metric columns while preserving the upload module, 270px height and desktop row. JavaScript syntax and 20 focused Web/static/trace tests pass; no upload, API or processing behavior changed | Current state/history, KPI-card evidence/index | +| 2026-07-31 | Center the shortened daily KPI cards | Vertically centered the shortened ARRIVAL DATE, processing duration and NO. OF ROOM cards within the upload module's shared desktop grid row. Their widths, 270px height, smaller decorative ring and mobile fallback remain unchanged. JavaScript syntax and 20 focused Web/static/trace tests pass; no upload, API or processing behavior changed | Current state/history, KPI-card evidence/index | +| 2026-07-31 | Tighten the daily KPI cards | Removed the percentage-based height inheritance that stretched ARRIVAL DATE, processing duration and NO. OF ROOM to the upload panel height. The three cards retain their widths, use a compact 270px desktop height with a smaller decorative ring, and remain content-sized on narrow screens. JavaScript syntax and 20 focused Web/static/trace tests pass; no upload, API or processing behavior changed | Current state/history, KPI-card evidence/index | +| 2026-07-31 | Add a monthly processing heading | Added the requested `月报处理` heading to the top of the monthly download list, paired with the existing live-update state without changing table columns, pagination, polling or data behavior | Current state/history, monthly-page evidence/index | +| 2026-07-31 | Recompose the daily report overview | Removed the duplicate top `Daily Report` heading and `THIS MONTH` eyebrow, renamed the history heading to `Daily Report`, and placed the ARR.XML upload station with the ARRIVAL DATE, processing-duration and NO. OF ROOM cards in one responsive desktop row. Existing upload/progress/task-log behavior is unchanged. JavaScript syntax and 20 focused Web/static/trace tests pass; no API or processing behavior changed | Current state/history, daily overview layout evidence/index | +| 2026-07-31 | Simplify the standard monthly report page | Removed the redundant standard-monthly heading, `VERSION HISTORY`, version-record title, visible monthly `版本` column and C/O-period footer note. Renamed the navigation item to `月报`, kept 50-row polling/pagination and API version ordering, and made `更新至` render the explicit maximum-ARRIVAL field with an `as_of_date` compatibility fallback. Added restrained live-status/table spacing; 32 focused Web/schema/task-log tests plus JavaScript/Python syntax checks pass. No report or business data changed | Current state/history, monthly-page evidence/index, scoped planning record | +| 2026-07-31 | Keep ARR.XML upload on the daily page and add progress feedback | Removed the automatic task-log popup after upload. Added an accessible inline progress bar with approximate upload, processor, validation and database stages, success/error states and mobile spacing. Manual header and history-row log access remain unchanged. `node --check`, five task-log/progress tests, two static Web tests and eight trace/repository tests pass; no API or processing behavior changed | Current state/history, daily upload evidence/index | +| 2026-07-31 | Attach Booking extraction records to their uploaded filename | Removed the separate accepted/historical-source status and summary from the company page. The open review now shows its draft's validated uploaded filename directly below `Booking记录提取`, clears it with the draft and wraps long names on 390px screens. JavaScript/HTML checks, 36 focused tests and the 346-test discovery pass with 10 expected skips; isolated desktop/mobile QA found no overflow or console errors and performed no mutation. Port 8766 restarted at 12:43 +08 | Current state/history, Booking evidence/index, business rules, module map, scoped planning record | +| 2026-07-31 | Freeze and save the Markdown company-channel test baseline | Created a read-only, non-publishing acceptance package bound to the exact Markdown/source-batch hash, processor/rule identity and five Finance version pins. Generated/reopened/value-checked five XLSX files and visually reviewed all 15 sheets. All 314 rows match an independent Markdown aggregation: 27 Booking Room values filled, 287 blank, zero mismatches and zero company errors. SHA-256 verification covers every retained file; the open Excel draft and active Booking batch were unchanged | Current state/history, new Markdown baseline evidence/index, stale item, scoped planning record | +| 2026-07-31 | Add Booking review multi-select and an in-page delete dialog | Added row/all-visible selection for 50-record pages, count-aware single/batch confirmation, explicit mouse/Escape cancellation and one strict 1-50-item atomic soft-delete API while preserving the item route. Removed the requested review/helper/source metadata copy so the source summary exposes only the uploaded filename. Focused suites and 346-test discovery pass with 10 expected skips; isolated desktop/390px browser QA selected 1/2/50 rows, showed no native prompt/overflow/console errors and submitted no deletion. Port 8766 restarted on the final composition at 12:22 +08 | Web README, business rules, data flow/module map, current state/history, Booking extraction evidence/index, scoped planning record | +| 2026-07-31 | Define Booking review end-to-end acceptance boundary | Confirmed the company-channel processor reads current Booking views, current July data is valid 5/5 and the expanded Booking/company/XLSX suite passes 55/55. A real-PostgreSQL draft/edit/activation slice now passes under an outer rollback. Remaining work is one operator-authorized complete-workbook replacement and a released populated period; July month-end opens 2026-08-01 00:00 Bangkok | Current state/history, extraction/company evidence/index, stale items, scoped planning record | +| 2026-07-31 | Build the raw Booking Excel extraction program | Added deterministic Tour Code/`โรงแรม` parsing, agreed TWN/DBL normalization, last-row/cancellation rules, quantity-preserving manual review, editable/deletable durable drafts, zero-pending atomic activation, upload-first responsive UI and real PATCH/DELETE HTTP transport. The supplied workbook yields 26 codes/37 items/7 pending and both named examples match. Migrations 014/015 are applied; a real-PostgreSQL create/edit/activate slice rolled back cleanly; 343 tests and authenticated browser QA pass; port 8766 runs the new composition with all readiness flags true and the original business source unchanged | Root/schema docs, business rules, data flow/module map, current state/history, extraction evidence/index, reflection/index, stale items, scoped planning record | +| 2026-07-31 | Audit whether Booking DB supports parser 2.0 human review | Initial snapshot found a coarse row gate, no review transport and no repository acceptance. The later extraction-program task applied 014/015, added PATCH/DELETE, passed a real-PostgreSQL transaction rollback slice and restarted the runtime. The remaining audit finding is that the latest-state draft model has no actor/reason/revision history | Current state/history, Booking review evidence/index, data flow/module map, stale items, migration record, scoped planning record | +| 2026-07-31 | Audit current database content and manual Booking import dimensions | Read-only snapshots found one 867-row/348-code Booking fixture and 417 active Finance rows: 121 matched, 285 missing-code and 11 unmatched. `Group Code + room type + quantity` is sufficient under a global-code invariant. The initial snapshot found migration 014 absent and parser/importer drift; the later manual-review audit superseded deployment status by observing concurrent 014/015 application | Current state/history, business rules, Booking data-flow/module map, evidence/index, stale items, applied-migration record, scoped planning record | +| 2026-07-31 | Simplify the ARR login page copy and hierarchy | Replaced the left marketing narrative with one `ARR Report` heading, removed the duplicate top-left brand, workflow steps, welcome/access copy and help/footer disclaimers, and changed field labels to `username`/`password`. Authentication behavior remains unchanged. JavaScript syntax, 33 focused tests and live 390x844/1280x720 checks passed with no overflow or browser logs | Current state/history, login-runtime evidence/index | +| 2026-07-31 | Restore access after another reported page failure | Confirmed the authenticated 8766 runtime and detached Screen process were healthy: loopback/LAN root redirect to login and health/login return 200. The failure was a DHCP address change from `.103` back to `.48`, not a service outage. Opened the working loopback login gateway in the in-app browser and recorded `http://192.168.3.48:8766/` as the current LAN entry; no runtime or business data changed | Current state, runtime-mismatch evidence/index, stale items | +| 2026-07-30 | Activate the ARR application login on port 8766 | Stored both Web credential values in separate Keychain items, added a mode-0700 credential-free local launcher, restored the prior controlled database/OSS/report composition under detached Screen and verified loopback/LAN login. Anonymous root/API gating, hardened session/CSRF, desktop/H5/history reads, all five readiness flags and logout revocation passed; live totals remained 24/4/3 and no business job or worker was started | Current state/history, login-runtime evidence/index, stale items, commitments, mixed-runtime reflection, scoped activation plan | +| 2026-07-30 | Recheck the complete ARR page outage after login work | Found no process or listener on 8766: loopback and current-LAN root/login/health/static probes are refused, old PID 37865 is gone, and no auto-restart service exists. The Wi-Fi address also changed from `.48` to `.103`. Available evidence cannot prove login work caused the process exit; it does prove current login-enabled source cannot start without operator-selected credentials. No runtime or business-data mutation was made | Current state, runtime-mismatch evidence/index, stale items | +| 2026-07-30 | Polish the Daily Report title, task-log utility and XML upload area | Rebalanced the workspace hierarchy so ARR Report outranks Daily Report, styled task log as a real 13px outlined button, centered and simplified the responsive upload station, removed the numbered/retention labels and reduced the primary action to a compact control. JavaScript syntax, 33 focused tests and live 375/768/1024/1440 checks passed with no overflow or browser issues | Current state, daily visual-polish evidence/index, scoped planning record | +| 2026-07-30 | Expand blank Booking Room fallback to unmatched Group Codes | Bumped the company-report processor to 1.2.0. A present consistent Group Code now retains its stay-segment/pricing row but leaves Booking Room blank when no accepted room can resolve; missing codes remain separate blank rows and Finance validation stays strict. A repeatable-read July 31 preview is valid 5/5 and 37 focused/real-XLSX/Web-coordinator tests passed with three expected skips. No data/report mutation occurred; the later port-8766 activation row records runtime loading | Business rules, current state, company-report readiness evidence/index, stale items, company-report README, scoped planning record | +| 2026-07-30 | Add an application login page and protect the ARR portal | Added fail-closed environment credentials, bounded login attempts, random authenticated sessions/CSRF, protected desktop/H5/API/upload/trace/download routes, revoking logout, safe return targets, no-detail `/healthz`, a responsive accessible login UI and Caddy HTTPS-only deployment. Final focused tests pass 33/33; the 317-test full suite is green; isolated browser checks pass all login/logout states and four responsive widths. Activation was intentionally deferred at source completion and is recorded by the later port-8766 activation row | Root/Web/deploy runbooks, success criteria, architecture/data flow/module map, business rules, current state/history, login-runtime evidence/index, reflection/index, stale items, commitments, scoped planning record | +| 2026-07-30 | Diagnose the reported unavailable ARR page | Proved the service was not generally down: loopback/LAN root, H5, health and JavaScript return HTTP 200, and a fresh root browser session has zero warnings/errors. `/login` alone is 404 because the long-lived process predates the login routes while serving newer static files. Opened the working root for the user and withheld an unsafe restart because required operator-selected login credentials are absent | Current state, runtime-mismatch evidence/index, scoped planning record | +| 2026-07-30 | Show the uploaded XML filename instead of internal `source.xml` in daily history | Added/applied migration 013 and separated run-level upload provenance from canonical artifact identity. History/trace now use only the uploaded basename; pre-013 rows render `—`. A rolled-back live DB probe and 312-test suite passed. The later port-8766 activation row resolves the credential/restart gate; one no-PII write-path check remains | Root/Web/migration runbooks, data flow/module map, business rules, current state, evidence/index, stale items, scoped planning record | +| 2026-07-30 | Move the daily task log into the former mobile-dashboard header utility | Replaced the desktop `手机看板` link with a visible, focusable `任务日志` trigger and moved the sole black console out of the 日报处理 layout into a native modal. Upload and mouse/keyboard history selection open the matching job; polling runs only while the modal is open. SQL proof confirms the console is limited to one `opera_daily` job and its linked attempts, deliveries/submissions, Finance daily versions and outbox facts—not global, monthly-run or company-report logs. JavaScript syntax, 12 focused tests and live 1440×900/375×812 checks passed with zero browser warnings/errors; the broader legacy Web test class remains blocked by separate in-progress login-contract drift | Current state, task-log relocation evidence/index, scoped planning record | +| 2026-07-30 | Add 50-row pagination to every monthly history list | Daily, monthly and selected-month company histories now request 50 rows per page and show exact total/range, page number and previous/next controls. Backend totals and page rows share one consistent snapshot/lock, old array-shaped API data remains compatible, monthly polling reconciles new publications back to page 1, and desktop/390-pixel live checks passed | Current state, module map, stale items, planning record | +| 2026-07-30 | Accept blank Booking Room when company detail has no Group Code | Confirmed the supplied 867-row/348-code Markdown is valid but already imported under the same hash. Changed the company-report rule so every no-code Finance fact remains a separate row with blank RES_COMMENT/Booking Room and no false duplicate/warning. Four July companies now preview valid; LianTai still has two present-but-unmatched codes. Focused tests and real XLSX export/reopen/render checks passed; no database or report mutation occurred | Business rules, current state, company-report readiness evidence/index, stale items, company-report README | +| 2026-07-30 | Enable the phone Channel BI dashboard on the current LAN | Restarted the validated ARR2 Web process from loopback-only to `0.0.0.0:8766` with the same controlled database, Keychain-backed OSS and report-generation configuration. Both `127.0.0.1` and `192.168.3.48` returned HTTP 200 for `/h5`; LAN health kept all readiness flags true and the analytics endpoint returned HTTP 200. The full portal, including upload controls, is now reachable from the trusted Wi-Fi | Current state | +| 2026-07-30 | Simplify ARR Report branding/copy and audit page display bounds | Replaced desktop/H5 header marks with plain `ARR Report`, changed the daily content heading to `Daily Report`, and removed the specified daily/monthly explanatory copy without changing automatic publication. Static scans, JavaScript syntax, 22 Web regressions and live desktop/monthly/390×844 H5 checks passed. Audited the current UI: daily/monthly/company history silently cap at 100 without pagination; BI renders uncapped aggregates, not detail rows | Current state, stale items, planning record | +| 2026-07-30 | Reconcile the user's 416-room correction | Confirmed the user was right about the total: 07-23 committed after the prior snapshot and four real sources now sum to 416. Hash matching and isolated replay prove `0720.XML=100` and `0722.XML=88`, so those two day labels were reversed. Live API/monthly V04 show 417 due solely to the fixture; an open BI view can separately remain stale at 308 because it does not refresh after upload. No business state changed | Current state, BI evidence/index, stale items, planning record | +| 2026-07-30 | Reconcile V01's claimed 2026-07-21 watermark | Proved the user is operationally correct: V01's OSS source has 119 rows all arriving 07-21. Its database lineage and hash-matched workbook also contain one 07-27 `local_fixture` row, so the persisted 7.27 label is contaminated content rather than a metadata-only error. No database/report state changed | Current state, BI contamination evidence, stale items, planning record | +| 2026-07-30 | Diagnose wrong-looking Channel BI values after data update | Proved BI reads current retained PostgreSQL facts and its aggregation code is green, but an accepted 07-27 `local_fixture` remained current. It contaminates BI/monthly V03 by +1 room, +3 room-nights, +5,400 and a false 7.27 watermark; also found `公司数` is actually worksheet-channel count. No database state changed | Current state, BI contamination evidence/index, prior BI/monthly evidence caveats, stale items, reflection, planning record | +| 2026-07-30 | Make published monthly versions appear without manual refresh | Removed the refresh control and added a four-second, visible-tab-only, non-overlapping list poll with immediate resume and last-good-state retention. Browser/access-log verification showed real repeated reads and current V03/V02/V01 downloads; latest included ARRIVAL remains the “更新至” authority | ADR-001/index, success criteria, data flow/module map, business rules, current state/evidence/reflection, root/Web runbooks | +| 2026-07-30 | Complete monthly publication, page visibility/download, automatic dispatch and formulas | Applied metadata-only migration 012; derived “更新至” from max included ARRIVAL; added durable idempotent report activation, real list/download, exact TOTAL PRICE formulas and a dedicated reliable worker. Two pending live events resolved to one active 120-row report ID 1/version 1 and both were acknowledged only after publication | ADR-001/index, architecture, business rules, current state, evidence/reflection, stale items, migration/root/Web/monthly/deploy runbooks | +| 2026-07-30 | Trace first live ARR2 daily and monthly user run | Proved the daily result was atomically committed as active Finance version 4 with 119 retained rows and a pending downstream event. Proved the manual monthly action generated a valid 120-row local XLSX but no database report metadata or Web-download identity, because ARR2 has no `finance.report_versions` and its monthly list is synthetic `source_ready` data | Current state, live-run evidence/index, stale items, planning record | +| 2026-07-30 | Build independent ARR2.0 programmatic XML ingestion | Copied a filtered source snapshot without touching ARR1; replaced Agent/MCP transport with local fixed processing, private immutable artifacts, independent validation and atomic ingestion; removed active callback/trace/deployment dependencies; added lifecycle/download/vertical-slice coverage and updated project memory | ADR-004, positioning, architecture, current state, evidence, reflection, commitments, stale items, runbooks | +| 2026-07-30 | Analyze first successful XML fetch/Skill/MCP run | Confirmed one exact fetch and one deterministic Skill run produced 135 records/119 output rows, followed by repeated large-file rereads and one MCP submission containing only 20 records. ARR rejected it as `RESULT_CONTRACT_INVALID`, wrote zero Finance versions and terminalized the job. XML allowlisting and MCP reachability are proven; reliable large-payload transfer is now the blocker | Current state, fetch evidence/index | +| 2026-07-30 | Diagnose frozen log that still displays RUNNING | Proved the four-second UI refresh is working but the task is no longer executing: SuperAgent completed and the trace stream ended, while ARR run/attempt remained `running` with no MCP submission or Finance write. The production upload lifecycle has no terminal poll/reconciler, PostgreSQL cannot persist `delivery_missing`, and grant expiry is not scheduled to close this case. No task state, service or remote run was mutated | Current state, fetch evidence/index, task-log evidence, stale items, planning record | +| 2026-07-30 | Analyze first true PROGRAM_INPUT v3 controlled run | Confirmed one HTTPS fetch call, no retry/bypass and a sole `extension_not_allowed` Tool result. Anonymous HEAD proved the public source URL/ACL and exact object identity. Skill/MCP did not run; the failure object was Profile-invalid and ARR remained running after remote completion, exposing a separate finalization defect | Current state, fetch evidence/index | +| 2026-07-30 | Reload local ARR with PROGRAM_INPUT v3 | Proved the prior job was generated by a stale v2 Web process, stopped only the validated 8765 listener, and restarted it through the controlled Keychain-backed launcher. PID 10376 now serves the updated source with all readiness flags true; a new job is still required for SuperAgent/MCP verification | Current state, local-runtime evidence, fetch experiment evidence | +| 2026-07-30 | Replace credential-backed/`oss://` fetch with ARR-generated public OSS URL | Added PROGRAM_INPUT v3 `oss.url`, exact HTTPS pass-through Prompt, percent-encoded key handling and ACL/readiness alignment. Only committed source XML is public-read; staging and outputs remain private. Forty-four targeted and 276 full-suite tests pass (2 skipped); SuperAgent republish, XML allowlist and live MCP/Finance proof remain external | ADR-003, processing/storage/integration READMEs, architecture, current state, fetch evidence, stale-item resolution, planning record | +| 2026-07-30 | Add one-click copy for the full task log | Added one plain button that copies the current black console's full `textContent`, with Clipboard API plus selected-textarea fallback, disabled/loading state and existing toast feedback. JavaScript syntax, 15 Web tests and live 8765 button behavior passed with zero console warnings/errors; browser clipboard isolation prevented external pasteback comparison | Web README, current state, task-log evidence, planning record | +| 2026-07-29 | Add real SuperAgent execution trace to the plain task-log console | Replaced production non-stream submit with one `include_trace=true` stream, returned on `run.started`, drained the same connection in the background, persisted only sanitized run/task/step/end JSONL and merged it with authoritative ARR facts. Verified POST-only/non-replay semantics, blocked ambiguous automatic retries, passed a 45-event live handoff probe, 38 targeted tests and the 275-test full suite (2 skipped). Existing pre-change jobs remain DB-only because trace cannot be replayed | Agent/Web/processing READMEs, architecture/data flow/module map, current state, evidence/index, planning record | +| 2026-07-29 | Replace processing status with a full-flow raw task log | Added a privacy-minimized trace API over persisted run/attempt/result/Finance/outbox facts and logical ordering for equal timestamps; after user feedback, reduced the frontend to one black monospace `
` with one event per line, task switching, polling and refresh. Real running/success/failure states, mobile containment, zero console errors, 20 targeted tests and 269 full-suite tests passed (2 skipped) | Web README, architecture/data flow, current state, evidence/index, planning record |
+| 2026-07-29 | Fix repeated XML upload source identity failure | Found that content-hash artifact deduplication reused an older run's job-scoped OSS key. Added/applied migration 011 and storage-identity lookup; 57 targeted and 269 full-suite tests pass. A real equal-content upload then reached SuperAgent under a new object identity, but remote success produced no MCP submission and no Finance write, so exported run events are the next blocker | Migration ledger, current state, repeated-upload evidence/index, memory index, planning record |
+| 2026-07-29 | Reduce the dedicated Agent Prompt to controlled orchestration | After the user clarified that only the fixed business-system entrypoint can invoke this Agent, removed redundant input gates and reduced the Prompt from 6,874 to 1,321 characters. ARR code, Skill and MCP retain authoritative validation; Prompt performs only one fetch/process/submit/output cycle. Twelve targeted tests pass; platform publication remains external | ADR-002, architecture, current state, manual-run evidence, planning record |
+| 2026-07-29 | Analyze exported SuperAgent manual XML run | Confirmed the daily Skill completed deterministically, but the run had no PROGRAM_INPUT, OSS fetch, submission grant, MCP call, receipt, or database evidence; its acceptance of chat upload and use of `present_files` prove the active Profile behavior does not match the required full Main Prompt | Current state, manual-run evidence/index, runtime-target reflection/index, planning record |
+| 2026-07-29 | Verify current Web “渠道BI” database connectivity | Confirmed the complete browser → Web API → PostgreSQL read path against the running 8765 instance: health and live analytics passed, the page rendered the matching 2026-07 QBD metrics with zero console errors, and 24 targeted tests passed. No connection code change was needed; the all-interface local listener was recorded as a deployment-boundary risk | Current state, BI runtime evidence/index, planning record |
 | 2026-07-29 | Open XML processing in the Docker image and current local Web entry | Added `--enable-processing` to Dockerfile CMD so direct image launches match Compose; updated/restarted the controlled local launchd entry and verified database/processing health plus an enabled XML file chooser; kept source CLI/runtime readiness fail-closed; passed 260 tests with 2 expected skips; pushed code commit `ad3d987` and verified the remote branch | README/runbook, current state, architecture module map, deployment and local-runtime evidence/index, container-entry reflection, planning record |
 | 2026-07-29 | Prepare and publish the project for controlled public-server deployment | Added Docker/Compose/Caddy deployment with XML processing explicitly enabled, HTTPS Secure cookies, Basic Auth/Bearer boundaries and a Chinese runbook; sanitized publishable history; 259 tests passed (2 skipped), all contracts/checksums and a 271-file secret/size/symlink scan passed; pushed `main` to the self-hosted repository and verified initial deployment commit `a701de9` | Current state, architecture, deployment evidence/index, commitments, module map |
 | 2026-07-29 | Restart the controlled ARR MCP public path | Restarted loopback MCP and fixed ngrok host; corrected exact public Host allowlisting after a diagnostic 421; public unauthorized requests now return 401 and authenticated one-tool discovery returns 200. Main upload remains disabled | Current state, E2E evidence/index, commitment, active planning record |
diff --git a/.project-docs/40-domain/business-rules.md b/.project-docs/40-domain/business-rules.md
index a250ee1..8a5cff1 100644
--- a/.project-docs/40-domain/business-rules.md
+++ b/.project-docs/40-domain/business-rules.md
@@ -2,19 +2,69 @@
 
 ## Durable Rules
 
+- Human access to desktop/H5 pages, detailed health, business APIs, uploads, traces and downloads requires an authenticated ARR Web session. Only login assets, `POST /api/login` and no-detail `/healthz` readiness are anonymous.
+- The single-operator login identity is injected as `ARR_WEB_USERNAME` / `ARR_WEB_PASSWORD`; real credentials are never stored in source or returned/logged. Missing credentials fail Web startup closed.
+- Login failures use a generic message and bounded attempt blocking. Logout revokes the server-side session; authenticated mutations continue to require CSRF.
 - The user uploads XML; the user does not submit a report year, report month, cutoff date, or separate monthly-generation request in the primary workflow.
-- The Agent returns structured data, which must be validated and successfully committed to the database before downstream monthly processing starts.
+- Daily history and task trace show the validated browser-uploaded XML basename. Canonical internal source name
+  `source.xml` is not user-facing; historical rows with no recorded basename display an unknown marker rather than a
+  guessed filename.
+- ARR's frozen processor generates complete structured data, which must be independently validated and successfully committed to the database before downstream monthly processing starts.
+- The production XML path must not require Agent, MCP, prompts, model-generated JSON, fetch grants, or a public source URL.
 - A successful database commit automatically triggers the monthly-report processing program through the business system.
+- Automatic monthly processing runs in a dedicated worker process, not inside the Web request process.
+- The worker derives the affected month from retained `ARRIVAL` facts belonging to the committed daily version.
+- The monthly `as_of_date`/“更新至” is the greatest `ARRIVAL` actually included in the current monthly snapshot. The XML filename and wall-clock date are never authorities for it.
 - Year, month, cutoff/as-of date, and similar fields are internal derived execution/audit values. Their presence in code or metadata does not make them user inputs.
 - The monthly output contains the exact business dimension/field `TOTAL PRICE`.
 - Each monthly XLSX data-row `TOTAL PRICE` cell is a formula whose business meaning is `REAL PRICE × NIGHTS × NO_OF_ROOMS` (current layout example: `=R2*C2*G2`).
 - A stored database `total_price` may be used for integrity checking, but it does not replace the required formula in the exported workbook.
-
-## Open Questions
-
-- The internal event/outbox/worker mechanism for automatic dispatch is not yet selected.
-- The exact business-time and cutoff derivation policy is not yet specified; the system must derive it without user input.
+- Monthly business/guest rows remain derived from current Finance facts. PostgreSQL persists only publication identity, lineage, channel counts and artifact metadata.
+- The outbox event is marked published only after a report is active/superseded and both workbook/result artifacts are registered; replay of the same snapshot must not create a duplicate publication.
+- A published monthly version automatically appears in the open monthly list; the primary workflow has no manual list-refresh step.
+- Company-channel detail generation is allowed for any period in the selected current month and for historical months. A
+  future report month is rejected so the system does not create an intentionally empty future workbook.
+- Company-channel C/O periods keep fixed report cutoffs: `01-10` uses the 10th, `11-20` uses the 20th and
+  `21-month-end` uses the selected month's natural month-end. The workbook uses the current committed Finance snapshot
+  through that cutoff even when the calendar period has not ended; later-arriving facts require a rerun to be included.
+- The company-channel UI labels calendar status as `周期未结束` or `周期已结束`. These labels describe whether the
+  selected period has reached its Bangkok completion boundary; they are not generation permission states.
+- In a company-channel detail workbook, a Finance fact with no normalized Group Code remains visible as its own row;
+  `RES_COMMENT` and `Booking Room` are blank, and this expected blank does not create a warning or duplicate highlight.
+- A Finance stay segment with a consistent nonblank Group Code keeps that Group Code and its normal aggregation. When
+  no current accepted Booking room can be resolved, only `Booking Room` is blank; the lookup miss does not create an
+  error or warning and does not block the company's workbook.
+- The company-report Booking allocation model consists of normalized Group Code, room type and quantity. A raw intake
+  adapter may derive these fields from a source workbook, but raw workbook columns and normalized result dimensions are
+  separate contracts; worksheet, physical row, artifact hash and parse version remain provenance.
+- A raw Booking workbook is recognized only on a worksheet containing one Tour Code/Group Code header and the exact
+  Thai `โรงแรม` header. Tour Codes are Unicode-normalized and all whitespace is removed before matching.
+- For repeated Tour Codes, the physically last source row replaces earlier rows. A final row with an explicit textual
+  cancellation removes the Tour Code; cell color or other formatting alone never means cancellation.
+- Room extraction reads parenthesized hotel segments. A `【label】` token takes the following integer as quantity and
+  defaults to one when no following integer exists. Multiple tokens become separate items even when they normalize to
+  the same room type.
+- Numeric suffix variants of `U-TWN` and `U-DBL` normalize to `U-TWN` and `U-DBL`; `高级房TWN`/`高级房DBL` normalize
+  to `TWN`/`DBL`. Unknown bracket labels retain their raw text and parsed/default quantity as pending. Recognized
+  unbracketed names such as `6+4`, `Family Suite`, `Junior Suite` and `Superior Garden` also remain pending because the
+  source omitted brackets. Surcharge, child-breakfast and guide-room tokens are not guest-room allocations.
+- Pending items do not contribute accepted Booking room quantity. Automatic and manual rows are both editable and
+  deletable; activation requires zero pending items and at least one confirmed item. Re-extraction rebuilds the review
+  draft from source and intentionally discards draft-only edits/deletions.
+- The review UI pages at 50 records. A delete request contains 1-50 unique item IDs and is all-or-nothing: every item
+  must still be undeleted, belong to the same reviewing draft and be deletable, otherwise the transaction rolls back.
+- An open Booking review displays the draft artifact's validated browser-uploaded filename directly below
+  `Booking记录提取`. Accepted historical-source readiness is not shown as provenance for draft extraction records.
+- Once accepted, a manually uploaded Booking workbook replaces the complete current source rather than appending
+  silently to the previous workbook. Migrations 014/015 and the HTTP GET/POST/PATCH/DELETE adapter represent this
+  boundary; the first real draft activation remains a controlled business acceptance action.
+- Booking normalizes the complete Group Code and globally aggregates repeated `(Group Code, room type)` rows by summed
+  quantity. Worksheet, company/channel and stay dates are not current Booking join dimensions. This contract therefore
+  requires one Group Code to identify one room allocation across those scopes; a different business reality requires a
+  schema/view/report-join change, not merely extra unused Excel columns.
+- Contradictory Group Code source fields and invalid Finance dates, nights or price details remain strict company-report
+  errors; the blank fallback applies only to absent or unresolved Booking enrichment.
 
 ## Last Reviewed
 
-2026-07-29
+2026-07-31
diff --git a/.project-docs/50-evidence/evidence-index.md b/.project-docs/50-evidence/evidence-index.md
index 0f171c5..2ab0c6b 100644
--- a/.project-docs/50-evidence/evidence-index.md
+++ b/.project-docs/50-evidence/evidence-index.md
@@ -4,8 +4,37 @@ Use this index for searchable, traceable evidence records.
 
 | Date | Topic | Status | Source | Detail |
 |---|---|---|---|---|
+| 2026-07-31 | Live 21–31 company-job Booking Room diagnostic | No Booking-write defect found | [Evidence topic](topics/2026-07-31-live-company-job-booking-room-diagnostic.md) | Published job `8aa1fdef…` contains 598 rows: 224 Booking Room values and 374 expected blanks. Of 986 Finance facts, 372 have no Group Code and two other output rows carry unmatched codes. Total Booking Price is Finance-derived, not a Booking-price calculation. |
+| 2026-07-31 | Company-channel early generation and period completeness state | Implemented; focused and project-venv full checks pass | [Evidence topic](topics/2026-07-31-company-report-early-generation.md) | Current-month incomplete C/O periods can be generated from the current committed Finance snapshot using fixed `as_of_date` boundaries; later facts require rerun, and future months remain blocked. UI states are `周期未结束` / `周期已结束`, with an early-generation confirmation note. No report or business data was generated during verification. |
+| 2026-07-31 | Company-channel period card copy and result details | Implemented; focused/full checks, month-boundary and deduplication checks pass | [Evidence topic](topics/2026-07-31-company-period-card-copy.md) | Removed stage labels, made the C/O ranges month-aware, collapsed duplicate review problems for display, and replaced the user-facing version number with generation time. Internal period keys, API version metadata, release rules and generation payloads are unchanged. |
+| 2026-07-31 | Company-channel card row and generation confirmation | Implemented; focused/full checks and isolated responsive QA pass | [Evidence topic](topics/2026-07-31-company-card-row-and-confirm-dialog.md) | Upload plus three C/O periods share one responsive card row; the month picker is compact and visually unlabeled in the setup card's upper-right; period CTAs read `生成`; native confirmation is replaced by an in-page dialog with cancel/backdrop/Escape dismissal. No report or business data was submitted during QA. |
+| 2026-07-31 | Markdown current source and July release recheck | Historical pre-change read-only state; superseded by early-generation behavior | [Evidence topic](topics/2026-07-31-markdown-current-source-release-recheck.md) | Batch 1 remains the current 867-row/348-code Markdown source and no review draft is open. The later live Finance projection has 986 facts, all with C/O 7/21–7/30; processor 1.2.0 previews valid 5/5 with 598 output rows, 224 filled Booking Rooms and 374 blanks. The original 14:14–14:17 check recorded the prior release gate; current behavior is documented in the early-generation topic above. |
+| 2026-07-31 | Company-detail title and upload rail | Implemented; focused/full checks and isolated responsive QA pass | [Evidence topic](topics/2026-07-31-company-detail-header-upload.md) | Fixed-company context is shown beside the page title in pale small text; the Excel dropzone/action rail is capped at 760px on desktop and stacks to the panel width on mobile. `报表月份` remains the C/O month selector and `刷新任务` remains read-only, with a tooltip documenting its refresh scope. No report/upload/database mutation occurred. |
+| 2026-07-31 | Company-channel action button compacting | Implemented; focused/full checks and isolated responsive QA pass | [Evidence topic](topics/2026-07-31-company-action-button-compact.md) | Period generation CTAs are 104–124px wide and centered in each card; `提取并核对` shares the 42px compact button family and centers in the upload action grid. Desktop/mobile geometry has zero center delta, one-line labels, no horizontal overflow or console warnings, and no business action was invoked. |
+| 2026-07-31 | Daily KPI card size refinement | Implemented; focused checks pass | [Evidence topic](topics/2026-07-31-daily-kpi-card-height.md) | ARRIVAL DATE, processing duration and NO. OF ROOM retain their original grid-column widths and stretch to the same compact desktop grid-row height as the ARR.XML upload panel. The upload internals are compressed into a horizontal dropzone; mobile behavior and processing are unchanged. |
+| 2026-07-31 | Daily overview layout and copy cleanup | Implemented; focused checks pass | [Evidence topic](topics/2026-07-31-daily-overview-layout.md) | The duplicate top `Daily Report` title and `THIS MONTH` eyebrow are gone; the history heading is `Daily Report`, and the upload station plus ARRIVAL DATE, processing duration and NO. OF ROOM cards share one responsive desktop row. Upload/progress/task-log behavior and all APIs are unchanged. |
+| 2026-07-31 | Standard monthly page cleanup | Implemented; focused checks pass | [Evidence topic](topics/2026-07-31-standard-monthly-page-cleanup.md) | The desktop monthly surface now keeps only a live-status rail, a six-column download table and 50-row pagination. Obsolete standard-monthly/version-history copy, the monthly version column and the C/O-period footer note are absent; `更新至` is backed by the explicit maximum-ARRIVAL API field. No report or business data changed. |
+| 2026-07-31 | Daily upload progress and task-log behavior | Implemented; focused checks pass | [Evidence topic](topics/2026-07-31-daily-upload-progress-and-log-behavior.md) | ARR.XML submission stays on the daily page, the existing task-log dialog remains on-demand, and the upload card shows an accessible stage-based estimate with success/error states. JavaScript syntax plus 15 focused Web/static/trace tests pass. |
+| 2026-07-31 | Markdown company-channel baseline freeze | Frozen and independently verified; live publication intentionally excluded | [Evidence topic](topics/2026-07-31-markdown-channel-baseline-freeze.md) | Exact Markdown hash/current batch 1 plus five Finance version pins produced five XLSX files and 314 rows. Independent Markdown comparison found zero Booking Room mismatches; all files pass SHA-256/ZIP checks and all 15 sheets passed visual review. One reviewing Excel draft remained isolated and no business/report write occurred. |
+| 2026-07-31 | Booking Excel extraction program | Implemented, migrated and runtime-active; first real source activation pending | [Evidence topic](topics/2026-07-31-booking-extraction-program.md) | Supplied workbook yields 26 Tour Codes/37 items/7 pending; review pages at 50, atomically batch-deletes 1-50 selected items and identifies the records with the draft's uploaded filename instead of a historical-source status. A real-PostgreSQL create/edit/activate slice rolled back cleanly, 346 tests pass, isolated desktop/mobile QA is green, and live July data previews valid 5/5. |
+| 2026-07-31 | Booking manual-review schema audit | Superseded implementation snapshot; audit-depth finding remains active | [Evidence topic](topics/2026-07-31-booking-manual-review-schema-audit.md) | The audit correctly found a PATCH/DELETE transport gap, which the later extraction-program implementation repaired. Its live-schema facts and reviewer/reason/revision-history gap remain valid. |
+| 2026-07-31 | Booking import dimensions and live database inventory | Booking findings active; Finance counts superseded by later live recheck | [Evidence topic](topics/2026-07-31-booking-import-dimensions-and-live-database.md) | The 867-row/348-code Booking inventory and normalized intake model remain valid. Its 417-row Finance count is a historical snapshot; the later read-only release recheck found 986 current supported-company facts. |
+| 2026-07-30 | Daily page visual polish | Active | [Evidence topic](topics/2026-07-30-daily-page-visual-polish.md) | The daily surface now preserves workspace-title hierarchy, exposes task log as a real button and centers a simplified upload station with a compact action. Thirty-three focused tests and four responsive live widths passed without overflow or browser issues. |
+| 2026-07-30 | Web login runtime mismatch | Resolved; authenticated runtime active | [Evidence topic](topics/2026-07-30-web-login-runtime-mismatch.md) | Keychain-backed login is active under detached Screen on `*:8766`. The live gateway is reduced to one ARR heading plus `username`/`password` fields while preserving auth behavior; 33 focused tests and two responsive browser widths pass. DHCP moved the current LAN entry back to `.48` on 2026-07-31; credential hardening and reboot-persistent supervision remain follow-ups. |
+| 2026-07-30 | Daily upload filename provenance | Implemented/migrated; post-restart write acceptance pending | [Evidence topic](topics/2026-07-30-daily-upload-filename-provenance.md) | Migration 013 separates `processing_runs.uploaded_filename` from canonical artifact `source.xml`; history/trace use only the upload provenance and old rows render unknown. The authenticated runtime is active; one controlled no-PII upload remains to verify the live write path. |
+| 2026-07-30 | Task-log relocation and daily-only scope | Historical before-state; superseded by the 2026-07-31 upload interaction update | [Evidence topic](topics/2026-07-30-task-log-relocation-and-scope.md) | The desktop header opens the black task console as a native modal, and history selection still reveals the matching job. The earlier upload-triggered opening is superseded; query inspection still proves both history and trace roots are limited to `pipeline_type = 'opera_daily'`. |
+| 2026-07-30 | Company-report path and July month-end readiness | Historical pre-change readiness snapshot; current generation rule superseded | [Evidence topic](topics/2026-07-30-company-report-path-and-month-end-readiness.md) | Missing/unmatched Group Codes leave Booking Room blank and current July projection previews valid 5/5. The topic records the prior July completion boundary and the remaining acceptance work; current-month incomplete periods are now also generatable, with rerun after completion when needed. |
+| 2026-07-30 | Channel BI post-update data contamination | Active; repair not yet authorized | [Evidence topic](topics/2026-07-30-channel-bi-post-update-data-contamination.md) | Four hash-matched real files total 416 rooms; live API/monthly V04 show 417 because active Finance version 2 is a one-room local fixture. An open BI view can also stay at pre-07-23 value 308 because analytics does not refresh after upload. |
+| 2026-07-30 | Monthly publication live acceptance | Implemented; current data caveat active | [Evidence topic](topics/2026-07-30-monthly-publication-live-acceptance.md) | Publication mechanics remain verified. Later runs reached V04 active with 417 rows and five published events; 416 are real and one is the known fixture. Browser/access logs previously proved automatic list discovery without refresh. |
+| 2026-07-30 | First live ARR2 user-run trace | Historical before-state; resolved | [Evidence topic](topics/2026-07-30-first-live-arr2-user-run.md) | The daily commit was real but the then-current monthly action was local-only. This diagnosis led to migration 012 and the now-complete publication/worker/formula repair. |
+| 2026-07-30 | ARR2.0 programmatic pipeline | Implemented; local vertical slices pass | [Evidence topic](topics/2026-07-30-arr2-programmatic-pipeline.md) | Independent no-Agent runtime runs the fixed processor, validates/stores artifacts and commits atomically. Success/failure, lifecycle SQL, private OSS behavior, trace, downloads and deployment contracts are covered. |
+| 2026-07-30 | SuperAgent OSS fetch Prompt experiment | Fetch/Skill/MCP reached; large payload rejected | [Evidence topic](topics/2026-07-30-superagent-fetch-oss-prompt-experiment.md) | The latest v3 job fetched the exact 629434-byte XML once and generated a valid 135-record result. After repeated reads of the roughly 124 KB JSON, the Agent submitted only 20 records. ARR rejected it as `RESULT_CONTRACT_INVALID`, wrote no Finance version and terminalized the job. XML allowlisting and MCP reachability are proven; exact large-payload transport through model arguments is the current blocker. |
+| 2026-07-29 | Full-flow task trace terminal | Verified locally and against live SuperAgent trace contract | [Evidence topic](topics/2026-07-29-task-trace-terminal.md) | New tasks use `include_trace=true`, persist only sanitized run/task/step events, and merge them with authoritative upload/writeback/Finance/outbox facts in one black console. POST-only semantics, non-deduplicating stream keys, background SSE handoff, 38 targeted tests and 275 full-suite tests with 2 skips were verified. On 2026-07-30 the same plain console gained one-click full-text copy with 15 Web tests and live button-state/feedback verification. |
+| 2026-07-29 | Repeated XML artifact identity fix | Upload fixed; MCP E2E blocked | [Evidence topic](topics/2026-07-29-repeated-xml-artifact-identity-fix.md) | Migration 011 fixed equal-content cross-run identity and a real `0721.XML` upload reached SuperAgent. The remote run returned success under MCP config 53 but made no submission; its grant expired unconsumed and Finance remained empty. |
+| 2026-07-29 | SuperAgent manual XML runs | Historical; production implication superseded by ADR-002 | [Evidence topic](topics/2026-07-29-superagent-manual-xml-run-bypassed-mcp.md) | The exports remain factual processor/chat evidence, but the user confirmed direct chat upload is outside the dedicated Agent contract; production acceptance starts at the controlled ARR entrypoint. |
 | 2026-07-29 | Local XML upload runtime re-enabled | Active until local restart/config change | [Evidence topic](topics/2026-07-29-local-xml-upload-runtime-reenabled.md) | The old launchd process omitted `--enable-processing`; its controlled launcher was corrected and restarted, health now reports database/processing ready, and the XML chooser is enabled without submitting a file. |
-| 2026-07-29 | Controlled public deployment repository | Active until server E2E | [Evidence topic](topics/2026-07-29-public-deployment-repository.md) | Dockerfile default CMD and Compose explicitly enable XML processing while source CLI/runtime readiness remain fail-closed; 260 tests pass with 2 skips, but the refreshed image and public runtime still require server verification. |
+| 2026-07-29 | Channel BI database runtime verification | Superseded after fact changes | [Evidence topic](topics/2026-07-29-channel-bi-database-runtime-verification.md) | The database connection finding remains true, but its one-row fixture projection is historical; use the 2026-07-30 post-update contamination evidence for current values. |
+| 2026-07-29 | Controlled public deployment repository | Historical baseline; current server E2E still pending | [Evidence topic](topics/2026-07-29-public-deployment-repository.md) | Dockerfile/Compose processing and fail-closed readiness evidence remains valid. Its Caddy Basic Auth/MCP topology is superseded: current deployment uses Caddy HTTPS plus ARR application login and no active MCP service. |
 | 2026-07-29 | Live synthetic XML upload vertical slice | Active blocker | [Evidence topic](topics/2026-07-29-live-synthetic-xml-vertical-slice.md) | Public MCP reachability and one-tool discovery are restored; stale SuperAgent config version 33 must now be refreshed before the next commit test. |
 
 ## When To Add Evidence
diff --git a/.project-docs/50-evidence/topics/2026-07-29-channel-bi-database-runtime-verification.md b/.project-docs/50-evidence/topics/2026-07-29-channel-bi-database-runtime-verification.md
new file mode 100644
index 0000000..6c4f318
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-29-channel-bi-database-runtime-verification.md
@@ -0,0 +1,41 @@
+# Evidence Topic: Channel BI database runtime verification
+
+## Metadata
+
+- Date: 2026-07-29
+- Status: Superseded
+- Scope: Local Web channel-BI read path from browser through PostgreSQL
+- Confidence: Fact
+- Source: Live local HTTP responses, browser DOM/console inspection, process/listener inspection and targeted automated tests
+- Last verified: 2026-07-29
+- Stale trigger: Restart or reconfiguration of the 8765 Web process; database migration or fact changes; changes to `arr_web`, `channel_analytics` or the BI frontend
+
+Superseded for current values by
+[`2026-07-30-channel-bi-post-update-data-contamination.md`](2026-07-30-channel-bi-post-update-data-contamination.md)
+after real uploads changed the Finance projection. The connection-path finding remains valid.
+
+## Question
+
+Is the current Web “渠道BI” page actually connected to PostgreSQL, rather than only having an unverified code path or mock response?
+
+## Evidence
+
+- Static path: `arr_web/static/app.js` requests `/api/months` and `/api/analytics`; `arr_web/app.py` delegates the analytics route to `PostgresPortalRepository`; `channel_analytics/postgres.py` reads current Finance facts in `REPEATABLE READ READ ONLY` transactions and validates the target database as `booking_test`.
+- Live health: `GET http://127.0.0.1:8765/api/health` returned HTTP 200 with `database_ready=true`.
+- Live database-backed requests: `/api/months` returned the `2026-07` projection with one channel and one row; `/api/analytics?month=2026-07` returned BI schema 1.2 with QBD, one sold room, three room nights and total price 5400.
+- Browser verification: the live “渠道BI” tab rendered the same month, KPIs, QBD ranking and channel-by-room-type matrix; browser console error count was zero.
+- Runtime identity: the existing Web process uses the controlled `booking-test-db.env` and is bound to `0.0.0.0:8765`; upload processing is disabled, matching `processing_ready=false`.
+- Regression: 24 tests covering channel analytics PostgreSQL behavior, analytics contracts, Web routes and repository schema passed.
+
+## Finding
+
+The current local Web channel-BI path is fully connected and functioning from the rendered page through the live Web API to PostgreSQL. No connection code change is required for this task. `processing_ready=false` is a separate XML-upload runtime state and does not invalidate the BI read path.
+
+## Impact
+
+Future channel-BI work should not begin by replacing or re-wiring the current repository. Reverify the live endpoints after process, configuration, migration or fact changes. Treat the existing all-interface listener as a local test process, not a production security boundary: bind it to loopback for local-only use or place it behind the documented Caddy HTTPS plus ARR application-login boundary.
+
+## Open Items
+
+- Reverify `database_ready=true`, `/api/months` and one known `/api/analytics` month after the formal server deployment.
+- Do not expose port 8765 directly; use loopback for local testing or the Caddy boundary for deployment.
diff --git a/.project-docs/50-evidence/topics/2026-07-29-local-xml-upload-runtime-reenabled.md b/.project-docs/50-evidence/topics/2026-07-29-local-xml-upload-runtime-reenabled.md
index 8aeb8c8..d5b14b8 100644
--- a/.project-docs/50-evidence/topics/2026-07-29-local-xml-upload-runtime-reenabled.md
+++ b/.project-docs/50-evidence/topics/2026-07-29-local-xml-upload-runtime-reenabled.md
@@ -7,7 +7,7 @@
 - Scope: Current local Web process on port 8765
 - Confidence: Fact
 - Source: launchd/process inspection, health response and in-app browser DOM inspection
-- Last verified: 2026-07-29
+- Last verified: 2026-07-30
 - Stale trigger: Restart or reconfiguration of `com.chillishark.opera-arr-report`, route/Keychain changes, or replacement of the 8765 Web process
 
 ## Question
@@ -27,6 +27,12 @@ Why was ARR.XML upload disabled in the currently running page, and is it actuall
 
 The upload was disabled because the long-running local launcher omitted the explicit processing feature flag. The corrected process has passed both the runtime readiness gate and the page-level control check.
 
+## 2026-07-30 PROGRAM_INPUT v3 reload
+
+Job `arrjob-1fd0f5e182eb4056af53be7300174981` exposed that the then-running PID 2891 had started at 10:13, before the v3 source files were modified at 11:14. Its 11:34 trace therefore repeated `arr-opera-daily-program-input-2` and ended in `status=running` before any model tool call. The four visible `fetch_oss_file` strings came from the same attachment policy copied through middleware, not four tool calls.
+
+PID 2891 was terminated only after its command and 8765 listener identity were revalidated. The same controlled launcher then started PID 10376 at 11:40. `GET /api/health` returned `database_ready=true`, `processing_ready=true`, `monthly_ready=true`, `download_ready=true`, `company_reports_ready=true` and `agent_writeback_ready=true`. A new job, rather than the old v2 job, is required to prove the loaded v3 contract at the remote Agent boundary.
+
 ## Impact
 
 The user can now select ARR.XML on the current local page. A real end-to-end result still requires SuperAgent to call the published MCP tool and an MCP `committed`/`already_committed` receipt; merely selecting or dispatching the file is not a database success signal.
diff --git a/.project-docs/50-evidence/topics/2026-07-29-public-deployment-repository.md b/.project-docs/50-evidence/topics/2026-07-29-public-deployment-repository.md
index 843e5c9..1fe006a 100644
--- a/.project-docs/50-evidence/topics/2026-07-29-public-deployment-repository.md
+++ b/.project-docs/50-evidence/topics/2026-07-29-public-deployment-repository.md
@@ -29,6 +29,12 @@ Does the publishable source snapshot open XML processing through both supported
 
 The repository snapshot now makes direct Docker-image launches and Compose launches consistent: both request XML processing, while the source CLI remains default-closed and the page opens upload only after the database, guarded OSS, SuperAgent and HMAC runtime initialize. The documented Compose topology protects Web traffic with Caddy HTTPS plus Basic Auth, and MCP retains application bearer authentication.
 
+## Authentication Supersession Note — 2026-07-30
+
+The paragraph above remains historical evidence for the 2026-07-29 published snapshot. Current ARR2 deployment no
+longer uses Caddy Basic Auth or an active MCP surface: Caddy terminates HTTPS, ARR Web owns login/session/CSRF/logout,
+and Compose injects `ARR_WEB_USERNAME` / `ARR_WEB_PASSWORD`. Use the current root and `deploy/` runbooks.
+
 ## Impact
 
 The server must rebuild/redeploy the image; a platform-level CMD override must retain `--enable-processing`. `processing_ready=true` remains the deployment prerequisite, but business completion still requires SuperAgent tool rediscovery and an MCP `committed`/`already_committed` receipt with matching database facts.
diff --git a/.project-docs/50-evidence/topics/2026-07-29-repeated-xml-artifact-identity-fix.md b/.project-docs/50-evidence/topics/2026-07-29-repeated-xml-artifact-identity-fix.md
new file mode 100644
index 0000000..054e333
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-29-repeated-xml-artifact-identity-fix.md
@@ -0,0 +1,40 @@
+# Repeated XML Artifact Identity Fix
+
+## Claim
+
+The Web upload failure `PROCESSING_SOURCE_INVALID: processing source object is unavailable` was caused by database content deduplication reusing an older run's artifact row. Migration 011 fixes that failure. A post-fix real upload now reaches SuperAgent, but the wider E2E remains blocked because the successful remote run did not call the direct-result MCP tool.
+
+## Runtime Evidence
+
+- Four repeated submissions created processing runs 17 through 20 between 20:29 and 20:32.
+- Each run had a queued attempt and an issued, unconsumed direct-MCP grant, but no remote run identifier and no result submission.
+- All four new runs pointed to the same `opera_xml` artifact row and therefore to the committed object key belonging to run 16.
+- `OssProcessingMessageBuilder` correctly requires the current `job_id` to be a path segment in the source object key, so it rejected those cross-run references before Open Agent dispatch.
+- The browser rendered the safe error text but incorrectly described the task as not created even though the database registration and grant already existed. That display issue remains separate from the identity fix.
+
+## Root Cause
+
+Migration 008 imposed `UNIQUE (artifact_kind, sha256)`, and `PostgresIngestionRepository._ensure_artifact()` looked up artifacts only by those two content fields. Reusing the same XML bytes in a new run therefore reused the old row and old job-scoped OSS key. This contradicted the intended rule that equal content may be processed in independent run/attempt identities.
+
+## Change
+
+- Migration 011 replaces the content uniqueness constraint with a non-unique lookup index. The existing storage-identity unique index remains authoritative.
+- `_ensure_artifact()` now finds an artifact by provider, bucket alias, object key and null object version, then verifies all immutable metadata before reuse.
+- Three repository regressions cover new-object insertion for equal content, exact storage-identity reuse and metadata conflict rejection.
+- Two migration regressions cover the forward shape and the guarded rollback refusal after repeated content exists.
+
+## Verification
+
+- 57 targeted ingestion, direct-ingestion, processing and Web tests passed.
+- The complete suite passed 269 tests with 2 expected environment skips.
+- An up/down transaction rehearsal passed against `booking_test`.
+- Migration 011 was formally applied; artifact row count remained 12 and the expected non-unique lookup index replaced the content uniqueness constraint.
+- The controlled 8765 service was restarted and reported all database, processing and writeback health gates as ready.
+- The user-authorized `0721.XML` was submitted through the real ARR session/CSRF upload endpoint as job `arrjob-51284ae50150454f9c085b461be7f0d8`. Its new source key contains that job identity, and two independent `opera_xml` artifact rows now exist for the equal content.
+- Remote run `3e17e1d3-3b06-4e9b-b008-e231770618b2` started successfully and the Open Agent API reports `success` with no platform error. It resolved the intended Profile and current enterprise MCP configuration version 53.
+- After the remote terminal state and an additional propagation window, the grant remained unconsumed, no result submission or Finance version existed, and the grant later expired. Therefore this run proves upload/dispatch but not MCP/Finance completion.
+- The authenticated Ops page could be opened, but structured log reads timed out twice. Exact Agent-step diagnosis requires the exported full run log; no further upload should be attempted until that log is reviewed.
+
+## Staleness Trigger
+
+Reverify after changes to artifact identity schema, `PostgresIngestionRepository._ensure_artifact()`, object-key policy, or the active 8765 runtime.
diff --git a/.project-docs/50-evidence/topics/2026-07-29-superagent-manual-xml-run-bypassed-mcp.md b/.project-docs/50-evidence/topics/2026-07-29-superagent-manual-xml-run-bypassed-mcp.md
new file mode 100644
index 0000000..588b030
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-29-superagent-manual-xml-run-bypassed-mcp.md
@@ -0,0 +1,51 @@
+# Evidence Topic: SuperAgent Manual XML Run Bypassed MCP
+
+## Metadata
+
+- Date: 2026-07-29
+- Status: Historical evidence; production requirement superseded by ADR-002
+- Scope: Manual SuperAgent XML test versus the accepted Web → OSS → Agent → MCP → PostgreSQL flow
+- Confidence: Fact for the exported run; strong inference for the published-Prompt mismatch
+- Source: User-exported `XML File Analysis (2).md`
+- Last verified: 2026-07-29 19:53 +08:00
+- Stale trigger: None; retain as historical evidence and interpret through ADR-002
+
+## Question
+
+Did the exported SuperAgent run exercise the accepted ARR database-ingestion path?
+
+## Requirement Correction
+
+The user confirmed that this Agent is dedicated to a fixed business-system entrypoint whose PROGRAM_INPUT is
+already constrained in code. Direct SuperAgent chat uploads are outside the production contract. Therefore these
+logs remain factual execution evidence but do not justify input-validation gates in Main Prompt. ADR-002 is the
+current authority.
+
+## Evidence
+
+- The 249-line export covers a manual XML conversation from 19:13:23 to 19:14:37.
+- The deterministic daily processor succeeded for business date `2026-07-21`; it reported 135 source records and 119 retained output rows.
+- Tool-name counts in the complete export were: `read_file=10`, `present_files=1`, `fetch_oss_file=0`, and `arr_submit_processing_result=0`.
+- The export contains no `PROGRAM_INPUT`, `submission_grant`, `job_id`/attempt submission identity, MCP receipt, or `enterprise_mcp_transport_unavailable` event.
+- The Agent accepted a chat-uploaded XML, processed a local upload path, and presented generated files. The accepted Main Prompt explicitly rejects chat uploads and forbids `present_files` on the direct-ingestion path.
+- The Agent's own reasoning says a “main agent prompt file” at a local user path was inaccessible. This is consistent with a Profile containing a file-path reference or an old prompt rather than the full contents of `prompts/arr_opera_daily_main_agent_prompt.md`.
+- A second 95-line export from 19:46 shows the compact Prompt recognized that direct `0722.XML` upload lacked PROGRAM_INPUT. It did not call the Skill, `fetch_oss_file`, `arr_submit_processing_result`, or `present_files`.
+- That run still called `read_file` once for 20.44 seconds and `ask_clarification` once before stopping, and it invented the field name `fetch_strategy` instead of `attachment_fetch_policy`.
+- A third 376-line export from 20:22 processed the chat path `/mnt/user-data/uploads/0721.XML` directly with shell tools and then called `present_files`. It contained zero `fetch_oss_file` calls, zero `arr_submit_processing_result` calls, no submission grant and no committed receipt. The Skill succeeded, but no OSS/MCP/database path ran.
+- The third run's reasoning still describes the superseded “validate PROGRAM_INPUT” Prompt and its `present_files` behavior violates the final controlled Prompt. That session therefore does not prove the latest Profile text was saved/published and selected.
+- The intermediate 3,095-character defensive Prompt was superseded after the user's boundary clarification. The final source is a 1,321-character controlled orchestrator with no duplicated input gates; a regression test caps it below 1,800 characters and preserves one fetch/Skill/MCP plus receipt boundaries. All 12 targeted tests pass. This source change is not evidence that SuperAgent has published it.
+
+## Finding
+
+The Skill and its deterministic processor worked, but this run did not fetch the source from ARR-controlled OSS, did not receive an attempt-bound submission grant, did not call ARR MCP, and did not write or prove any database facts. It is a processor-only manual test, not an end-to-end ARR test.
+
+The runs do not prove production Profile correctness because they bypassed the supported entrypoint. A valid E2E
+must begin with ARR-generated PROGRAM_INPUT and then show `fetch_oss_file`, the Skill, the MCP submission and a
+committed database result.
+
+## Impact
+
+- Do not use manual SuperAgent chat uploads as production acceptance tests.
+- Paste and publish the final controlled-orchestration Prompt with the MCP tool loaded.
+- Test through ARR Web so the backend supplies the validated PROGRAM_INPUT and grant.
+- Accept only `committed`/`already_committed` plus matching PostgreSQL facts.
diff --git a/.project-docs/50-evidence/topics/2026-07-29-task-trace-terminal.md b/.project-docs/50-evidence/topics/2026-07-29-task-trace-terminal.md
new file mode 100644
index 0000000..7175db3
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-29-task-trace-terminal.md
@@ -0,0 +1,109 @@
+# Task Trace Terminal Verification
+
+## Scope
+
+Replace the decorative daily-processing status module with a diagnostic view
+that exposes the persisted task flow from upload through downstream outbox
+state. After the first structured UI pass, the user explicitly required the
+plainest possible presentation: one black code console with raw log lines.
+
+## Implemented Evidence Path
+
+- `GET /api/jobs/{job_id}/trace` validates the job identifier and asks the
+  repository for a privacy-minimized diagnostic projection.
+- New tasks are submitted once through
+  `POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true`.
+  The first `run.started` binds the ARR attempt; the same generator is then
+  consumed on a background thread until the SuperAgent run terminal event and
+  SSE `end`.
+- Raw SSE and assistant text never reach disk. `arr_processing.agent_trace`
+  persists only allowlisted run/task/step/end projections in a private,
+  append-only local JSONL store keyed by the job-id digest.
+- `arr_web.job_trace` reconstructs ordered events from the processing run,
+  attempts, artifact deliveries or direct MCP submissions, Finance daily
+  versions and downstream outbox events, then merges the sanitized SuperAgent
+  events by timestamp.
+- Equal PostgreSQL transaction timestamps use an explicit business-sequence
+  tie-breaker so validation/Finance commit precede attempt/job success and
+  downstream queue events.
+- The desktop page renders the response in a single black monospace `
`.
+  Each event is one line containing timestamp, level, stage, code, title,
+  message and allowlisted details JSON. The controls remain limited to
+  historical task selection, four-second active-task polling, manual refresh
+  and one-click copying of the complete current `
` plaintext.
+
+## Privacy Boundary
+
+The SQL and projection expose only diagnostic columns. `message.delta` and
+`message.final` are always dropped. Trace summaries redact credential
+assignments, Bearer values, URLs, absolute paths, emails, long numbers and XML
+fragments before persistence, and the read path validates the field/details
+allowlist again. The response and UI do not contain grants, credentials,
+private object paths, source bytes, raw payload/envelope/receipt JSON or guest
+PII. Outbox rows are described only as queued/published notifications, not as
+proof of monthly-report completion.
+
+The SuperAgent endpoint accepts only POST and does not replay historical runs.
+It was also verified to create a different `run_id` when the exact same message
+and idempotency key are sent twice. ARR therefore never POSTs a second stream to
+backfill logs. An uncertain failure before `run.started` is terminalized as
+`PROCESSING_REMOTE_SUBMISSION_AMBIGUOUS` rather than automatically retried.
+
+## Runtime Checks
+
+An isolated read-only Web instance on `127.0.0.1:8766` used the configured
+`booking_test` database while the standing 8765 process was left untouched.
+
+- A current queued/running task rendered upload, attempt and current-state
+  events and continued four-second polling.
+- Successful fixture `mvp-v1-fixture-20260727` rendered 11 events in the
+  required upload → dispatch → result → validation → Finance → job → outbox
+  order.
+- Failed task `arrjob-fdc2c1a0695c41b3b372a52033b2f567` rendered six events
+  and the terminal line
+  `FAILURE stage=writeback code=PROCESSING_RESULT_MISSING`.
+- Computed UI evidence was `tag=PRE`, black `rgb(8, 11, 16)`, monospace font,
+  and zero old filter/timeline/evidence elements.
+- At a 375×812 viewport, document width stayed 375 pixels; long raw lines
+  scroll only inside the console. Browser console error/warning count was zero.
+- Live contract probes confirmed `405 Allow: POST` for GET/HEAD/OPTIONS, the
+  outer `trace` SSE event, inner `run.started/task.updated/step.updated/`
+  `message.delta/message.final/run.completed`, and terminal `end`.
+- A live isolated background-handoff probe returned the run handle and then
+  persisted 45 safe events (28 task updates and 14 step updates) through
+  completion/end with no capture failure.
+- The controlled local LaunchAgent was restarted after implementation. Port
+  8765 reports database/processing/writeback/monthly readiness, serves
+  `arr-job-trace-2`, and correctly returns `agent_trace_events=0` for a job that
+  predates trace capture.
+- On 2026-07-30, the live 8765 page showed the copy control disabled before a
+  trace loaded and enabled for a 228-line terminal containing the final `END`
+  line. A real click completed the page Clipboard API promise and displayed the
+  existing success toast; browser console warning/error count remained zero.
+  The in-app Browser isolates page, automation and macOS clipboard channels, so
+  no external pasteback-equality claim is made. Source and regression checks
+  verify that the exact `#process-log.textContent` is passed to the Clipboard
+  API, with selected-textarea fallback and explicit failure feedback.
+
+## Automated Verification
+
+- `node --check arr_web/static/app.js`: pass.
+- 38 post-hardening targeted tests: pass.
+- Full repository suite: 275 tests passed with 2 expected artifact-tool skips.
+
+## Result
+
+The status module now reports the real SuperAgent execution path plus the
+authoritative persisted ARR processing chain in the user-requested plain
+terminal form. It locates failures by stage and stable error code without
+treating Agent trace as proof of Finance commit or widening the privacy surface.
+Jobs submitted before this capture path was enabled retain only their persisted
+ARR facts because historical Agent trace cannot be replayed.
+
+The 2026-07-30 v3 run also established an important limitation: trace capture can
+finish normally while the authoritative ARR job remains `running`. Four-second
+refresh continued and the trace ended at `AGENT_TRACE_STREAM_ENDED`, but no MCP
+callback arrived and the production lifecycle had no terminal reconciler. The UI
+was faithfully rendering stale backend state; trace completion alone must not be
+promoted to business success, but it must trigger or feed a separate terminal
+no-submission reconciliation path.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-arr2-programmatic-pipeline.md b/.project-docs/50-evidence/topics/2026-07-30-arr2-programmatic-pipeline.md
new file mode 100644
index 0000000..9101ca5
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-arr2-programmatic-pipeline.md
@@ -0,0 +1,32 @@
+# ARR2.0 Programmatic Pipeline Evidence
+
+## Scope
+
+Independent workspace `/Users/chillishark/Desktop/ARR2.0`; source workspace remained read-only.
+
+## Implementation Evidence
+
+- Active Web composition imports PostgreSQL, OSS, local processor and generic ingestion only.
+- Compose contains one application service and no MCP port/domain/service or Agent secrets.
+- Root requirements do not install Agent integration or MCP packages.
+- Old callback route returns 404 and health has no Agent readiness field.
+- All ARR-managed OSS objects use private object ACLs.
+
+## Behavioral Evidence
+
+- Real success fixture: 5 source records, 3 retained records, four committed artifacts, active Finance version.
+- Real pricing failure fixture: result + structured + exception artifacts, recorded terminal failure, no active version.
+- PostgreSQL fake-cursor tests verify running and infrastructure-failure transitions plus failure outbox SQL.
+- Daily download test materializes the committed object and rejects a descriptor hash mismatch.
+
+## Verification Snapshot
+
+- Focused Web/download/programmatic/trace/OSS/deployment suite: 25 passed.
+- Focused ingestion PostgreSQL/programmatic suite: 13 passed.
+- Final full discovery: 282 passed-or-skipped in 125.8 seconds, with 0 failures/errors and 9 explained skips (3 private
+  booking fixtures intentionally not bundled; 6 optional XLSX renderer/tool checks).
+
+## Remaining External Verification
+
+Docker/Caddy and a real test database/OSS deployment are not available in this workspace. One controlled no-PII server
+vertical slice is still required before production rollout.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-channel-bi-post-update-data-contamination.md b/.project-docs/50-evidence/topics/2026-07-30-channel-bi-post-update-data-contamination.md
new file mode 100644
index 0000000..78ed7e5
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-channel-bi-post-update-data-contamination.md
@@ -0,0 +1,116 @@
+# Evidence Topic: Channel BI post-update data contamination
+
+## Metadata
+
+- Date: 2026-07-30
+- Status: Active
+- Scope: ARR2 local Web port 8766, controlled `booking_test`, Channel BI and July monthly publication
+- Confidence: Fact
+- Source: source/hash inspection, isolated deterministic replay, live privacy-minimized APIs, repeatable-read read-only PostgreSQL reconciliation and focused tests
+- Last verified: 2026-07-30
+- Stale trigger: any change to July current Finance pins, retirement of fixture version 2, monthly republication, or BI grouping/label behavior
+
+## Question
+
+What data does Channel BI consume, and why did its July values look wrong after the latest uploads?
+
+## Evidence
+
+### Implemented source and formulas
+
+- The browser calls `/api/analytics?month=YYYY-MM`; `PostgresPortalRepository` delegates to
+  `PostgresAnalyticsRepository` against `booking_test`.
+- Month/date pins come from `finance.current_daily_versions`, channel membership/order/count expectations from
+  `finance.daily_channel_metrics`, and aggregates/detail from current retained `finance.v_active_daily_facts`.
+- Sold rooms are `sum(no_of_rooms)`, room nights are `sum(no_of_rooms * nights)`, and revenue is
+  `sum(total_price)` without multiplying the stored total again. Monthly XLSX files are not a BI source.
+
+### Live reconciliation
+
+The active ARR2 Web API on port 8766 and direct PostgreSQL reads return the same current projection. Legacy port 8765
+is no longer listening.
+
+| Business date | Daily version | Provenance | Retained rows | Revenue | Current role |
+|---|---:|---|---:|---:|---|
+| 2026-07-20 | 5 | `0720.XML`, hash-matched real upload | 100 | 370,500 | valid real daily slice |
+| 2026-07-21 | 4 | real ARR upload | 119 | 452,000 | V01 operational source |
+| 2026-07-22 | 6 | `0722.XML`, hash-matched real upload | 88 | 269,700 | valid real daily slice |
+| 2026-07-23 | 7 | `0723-RES_DETAIL-修正版.xml`, hash-matched real upload | 109 | 359,150 | valid real daily slice |
+| 2026-07-27 | 2 | `mvp-v1-fixture-20260727` / `synthetic.xml` / `local_fixture` | 1 | 5,400 | test contamination |
+
+- The 07-23 upload committed at 16:12:27, after the earlier 15:44 diagnostic snapshot. This explains why that snapshot
+  contained only 307 real rows; it did not prove the four-day business total.
+- Excluding fixture version 2 now yields 416 rows/rooms, 853 room-nights, 1,451,350 revenue, nine room types, six
+  worksheet channels and latest real ARRIVAL 2026-07-23.
+- The live BI includes the fixture and therefore returns 417 rows/rooms, 856 room-nights, 1,456,750 revenue, ten room
+  types and `更新至 2026-07-27`. Its extra type is explicitly `SYNTHETIC ROOM TYPE`.
+- July monthly V04 is active with 417 rows; V01–V04 all inherit fixture version 2 and its false operational 7.27
+  watermark.
+
+### Source identity and date attribution
+
+- Immutable source SHA-256 maps the accepted sources exactly to local originals: `0720.XML` is version 5 and
+  `0722.XML` is version 6; `0721.XML` and `0723-RES_DETAIL-修正版.xml` map to versions 4 and 7 respectively.
+- The processor derives business date from agreeing XML `GROUPBY1_SORT_COL`/`GROUPBY1_COL` values and requires each
+  retained reservation ARRIVAL to match it; filename and upload order are not date authorities.
+- An isolated replay independently reproduced `0720.XML -> 2026-07-20 -> 100 retained` and
+  `0722.XML -> 2026-07-22 -> 88 retained`. The user was correct about the 416 total but had those two day counts
+  verbally reversed.
+
+### Frontend freshness
+
+- Channel BI loads at page boot, month change, or first BI-tab entry only. A successful XML upload refreshes daily jobs
+  but not analytics, and later BI-tab entries skip reload once `state.analytics` exists.
+- Therefore an already-open page can continue showing the pre-07-23 value 308 even though the API has advanced to 417.
+  Unlike the monthly list, Channel BI has no polling or visibility-aware reload.
+
+### Exact V01 reconciliation
+
+- V01 report ID 1 is persisted as superseded, `as_of_date=2026-07-27`, 120 rows and five channels.
+- Its legitimate OSS source lineage is daily version 4: 119 retained rows, all with `ARRIVAL=2026-07-21`.
+- Its only later lineage is daily version 2: one retained row with `ARRIVAL=2026-07-27`, accepted under run
+  `mvp-v1-fixture-20260727` from `synthetic.xml` using provider `local_fixture`.
+- Reopening the exact registered V01 workbook independently found the same 119/1 ARRIVAL distribution. The file is
+  20,741 bytes and its SHA-256
+  `a43cdac3f6d97f6b73f635d0f386cffd0ad8b6416e8f92a7b6afc2f79f1e8635` matches the artifact registry.
+- Therefore the user's operational statement “V01 is updated through 2026-07-21” is correct. The stored 7.27 label is
+  the consequence of contaminated report content, not a filename parser or a metadata-only rendering defect.
+
+### Channel/company semantics
+
+- Six channel keys are valid for these real facts: LianTai intentionally splits into `LIANTAI-GROUP` and
+  `LIANTAI-FIT`, alongside QBD, DY-AI-Easy-KB, FENGRUN and HanaTour.
+- The BI card renders worksheet-level `channel_count` but labels it `公司数`; six channels can represent five top-level
+  companies, so the label is misleading even after data cleanup.
+
+### Regression and mutation boundary
+
+- The corrected focused run passed 24 analytics-contract, PostgreSQL-provider and Web tests.
+- All database investigations used `REPEATABLE READ READ ONLY`; no Finance pointer, version, artifact or report was
+  changed during diagnosis.
+
+## Finding
+
+Channel BI is correctly reading and aggregating its configured current PostgreSQL facts. One proven cause of the
+reported mismatch is a data-hygiene incident: a database acceptance fixture remained pinned as a current Finance day
+after real uploads were enabled. It explains the one-room difference between the correct real total 416 and live API
+total 417, as well as every false 7.27 watermark. A second frontend defect can leave an open BI view showing 308 after
+the 07-23 commit. The user's 07-20/07-22 verbal attribution was reversed, but this does not change the 416 total. A
+smaller UX issue independently mislabels worksheet-channel count as company count.
+
+## Impact
+
+- Business-facing July BI and monthly V04 are not clean acceptance outputs until fixture version 2 is removed from the
+  current projection and a clean July monthly version is published.
+- Historical V01 cannot be truthfully corrected by changing only its `as_of_date` or filename because the registered
+  workbook itself contains the fixture row. Preserve that immutable artifact as contaminated audit history and publish
+  a clean operational replacement after the current projection is corrected.
+- Remediation should preserve immutable audit history; retire/unpin the fixture instead of deleting its version rows.
+- Production/readiness checks should reject any current Finance source whose provider is `local_fixture` (or other
+  explicitly non-operational provenance).
+
+## Open Items
+
+- Obtain authorization for the controlled Finance-pointer repair and clean monthly republication.
+- Make Channel BI refresh after successful upload and/or on visible BI-tab re-entry so it cannot retain an old snapshot.
+- Decide whether to relabel the BI card as `渠道/子表数` or add a distinct five-company aggregation.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-company-report-path-and-month-end-readiness.md b/.project-docs/50-evidence/topics/2026-07-30-company-report-path-and-month-end-readiness.md
new file mode 100644
index 0000000..2284809
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-company-report-path-and-month-end-readiness.md
@@ -0,0 +1,98 @@
+# Evidence Topic: Company-report path and July month-end readiness
+
+## Metadata
+
+- Date: 2026-07-30
+- Status: Processor and Booking runtime ready; released 5/5 live job pending
+- Scope: ARR2 local Web on port 8766, accepted `company_reports` processor, controlled `booking_test` snapshot
+- Confidence: Fact
+- Source: code/input inspection, live health/job metadata, repeatable-read aggregate queries, pure in-memory report build, real XLSX tests
+- Last verified: 2026-07-31 11:34 +08:00
+- Stale trigger: port-8766 restart, first Booking draft activation, Finance current-version repair, Booking source/parse changes, or a new July upload
+
+## Question
+
+What path and rules does the current “公司渠道明细” program use, and will the July month-end run publish all five
+workbooks from the current database projection?
+
+## Evidence
+
+- The active Web process includes `--enable-company-reports`; loopback health reported `company_reports_ready=true`
+  at 2026-07-30 15:35 Asia/Bangkok.
+- The Web coordinator accepts one month and one cumulative cutoff period, enforces the Bangkok release boundary, and
+  invokes the deterministic processor for the fixed five-company set. It does not read XML or call an Agent.
+- The source query reads `finance.v_active_daily_facts` by `DEPARTURE` from month start through the cutoff in a
+  repeatable-read, read-only transaction. Booking enrichment comes only from current accepted room items, joined by
+  normalized complete Group Code.
+- Two persisted Web jobs for 2026-07 cutoff 10 and cutoff 20 both completed 5/5 successfully with zero rows for every
+  company. The current supported-company facts all have C/O in the 21-to-month-end period, so those are valid empty
+  workbooks rather than dropped rows.
+- A read-only July 31 aggregate found 417 supported-company facts: 121 `matched`, 11 `unmatched`, and 285
+  `missing_group_code`. Missing Group Code counts are LianTai 120, QBD 128, DY-AI-Easy-KB 1, FengRun 33 and HanaTour 3.
+- Under the prior strict rule, a pure in-memory preview blocked all five companies because every missing Group Code was
+  an error. The user then explicitly chose the deterministic fallback: keep each such Finance fact as its own output
+  row with blank `RES_COMMENT` and blank `Booking Room`, without warning or duplicate highlighting. At that intermediate
+  step, present-but-unmatched Group Codes still remained strict source-completeness errors.
+- The supplied Markdown `/Users/chillishark/ARR项目0727/outputs/res_comment_20260727/RES_COMMENT_TYPE_OF_ROOM.md` matches
+  the dedicated fixed Booking Markdown importer: SHA-256
+  `ec2302170c5657d3e465691997e17f7694eb5a7f76feba7e43261632209b8c23`, 867 rows, six source worksheets and 348 distinct
+  Group Codes. The controlled database already contains an accepted/current batch with the same hash and counts, so
+  re-importing this exact file is unnecessary and would be idempotent.
+- After the fallback change, the same read-only July 31 preview produced:
+  - LianTai: invalid; 136 candidate rows, including 120 blank Booking Rooms; two present Group Codes
+    (`LLT260719MA`, `LT260718KC`) still lack accepted Booking matches; two multi-price review warnings remain.
+  - QBD: valid; 139 rows, including 128 blank Booking Rooms; four multi-price review warnings.
+  - DY-AI-Easy-KB: valid; one row with blank Booking Room.
+  - FengRun: valid; 33 rows with blank Booking Room.
+  - HanaTour: valid; three rows with blank Booking Room.
+- At that intermediate step, a source/business error still blocked only the affected company while warning-only reports
+  could publish, making four companies publishable and leaving only LianTai blocked.
+- The user then explicitly broadened the rule: both an absent Group Code and a present Group Code with no resolvable
+  Booking room leave `Booking Room` blank. Processor 1.2.0 keeps a present code's normal
+  `(Group Code, ARRIVAL, DEPARTURE)` aggregation and Finance pricing; lookup absence alone creates no error or warning.
+- The post-change repeatable-read July 31 preview is valid 5/5 with zero errors:
+  - LianTai: 138 rows, 122 blank Booking Rooms and two existing non-blocking multi-price warnings.
+  - QBD: 139 rows, 128 blank Booking Rooms and four existing non-blocking multi-price warnings.
+  - DY-AI-Easy-KB: one row/one blank; FengRun: 33/33 blank; HanaTour: three/three blank.
+- The current Finance projection also contains one `local_fixture`/`synthetic.xml` QBD fact with C/O 2026-07-30. The
+  company-report source does not filter by storage provider, so the month-end snapshot includes that fact.
+- The final scoped company/Booking/report suite ran 32 tests successfully with three expected private-fixture skips;
+  real artifact-tool coverage included five-company XLSX generation, workbook reopen/value validation, rendering and a
+  dedicated end-to-end blank-Booking-Room case. Four Web company-task coordinator tests also passed. No database import,
+  report publication or Finance mutation was performed.
+- The historical port-8766 Web process started at 17:13:54 +08, after the company-report core file's 17:10:03 +08
+  change; it loaded the earlier no-code-only fallback. Processor 1.2.0 was written at 17:34:13 +08 and was not loaded
+  in that superseded process.
+- Processor 1.2.0 passed 37 company/Booking/real-XLSX/Web-coordinator tests with three expected private-fixture skips.
+  The real builder exported, reopened and value-checked both absent-code and present-but-unmatched blank cells.
+- A non-publishing real-data vertical slice loaded the controlled July 31 snapshot in a repeatable-read/read-only
+  transaction and built all five workbooks in a temporary directory. All five exported, reopened, value-checked and
+  rendered three previews; row counts were 138, 139, 1, 33 and 3 respectively. The temporary artifacts were removed
+  automatically and no report/database state changed.
+- The authenticated replacement runtime now loads current source and reports `company_reports_ready=true`; activation
+  preserved three existing job records and deliberately did not submit a new report job.
+- A later 2026-07-31 read-only audit first found 014 absent, then observed a concurrent 014/015 deployment at 11:19.
+  Batch 1 is now the explicit current source and draft tables are empty. Port 8766 restarted at 11:30 with Booking
+  source-upload readiness and PATCH/DELETE transport active. Historical fixture reads and processor readiness remain
+  valid. See the later Booking extraction-program evidence.
+
+## Finding
+
+The supplied Markdown is a valid Booking source and its historical accepted rows remain queryable. Processor 1.2.0
+makes both absent and unresolved Booking enrichment non-blocking and the current July projection previews valid 5/5.
+`LLT260719MA` and `LT260718KC` remain visible business codes with blank Booking Room rather than report errors. The
+processor runtime and Booking upload/source gate are active. A controlled real draft activation and one released
+populated company-report job are still required before validating the new end-to-end flow. No additional Booking content
+is required for the historical lookup baseline.
+
+## Impact
+
+- Do not interpret the two zero-row successful jobs as proof that the month-end data set is publishable.
+- Current Web jobs use the activated processor 1.2.0 runtime, but readiness alone is not proof of a published 5/5 run.
+- Retire the known fixture from the current projection without deleting immutable history before treating a July
+  month-end company report as clean business output.
+
+## Open Items
+
+- Accept one controlled Booking review activation, then run one month-end Web job after the 2026-08-01 00:00 Bangkok
+  release boundary; verify all five downloads and their registered hashes.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-daily-page-visual-polish.md b/.project-docs/50-evidence/topics/2026-07-30-daily-page-visual-polish.md
new file mode 100644
index 0000000..ddec70b
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-daily-page-visual-polish.md
@@ -0,0 +1,40 @@
+# Daily Page Visual Polish
+
+## Metadata
+
+- Date: 2026-07-30
+- Status: Active
+- Scope: Desktop daily-page title hierarchy, task-log utility and XML upload composition
+- Confidence: Fact
+- Source: `arr_web/static/`, focused tests and live local browser verification
+- Last verified: 2026-07-30
+- Stale trigger: Masthead, daily heading, upload-panel or header-utility styling changes
+
+## User-Facing Outcome
+
+- `ARR Report` is the workspace identity and is visually larger than the daily page title at every verified width.
+- `任务日志` remains the same semantic dialog button but now has a compact outlined treatment and the same 13px label
+  size as the adjacent database status.
+- The upload station is centered at desktop, fills the available shell at smaller widths and retains the real
+  file-input/drop behavior.
+- Decorative `01 / UPLOAD` and `本月留痕` labels were removed.
+- `开始处理` moved into a compact action footer without changing its disabled, loading or submission behavior.
+- No API, upload field, route, task-log scope or business rule changed.
+
+## Verification
+
+- `node --check arr_web/static/app.js`: passed.
+- Authenticated Web/auth/daily-visual/task-log tests: 33 passed.
+- 1440x900: workspace/daily titles compute to 24px/21px; the upload card is centered at 840px; task-log/status labels
+  are both 13px; the compact action is 118x40.
+- 1024px: the upload card is centered at x=92 with width 840px.
+- 768px: the upload card fills the 720px shell.
+- 375x812: titles compute to 20px/18px; the upload card fills 347px; the action is 112x42.
+- All four widths have zero horizontal overflow. Browser warning/error count is zero.
+- Opening the restyled task-log button focuses the native dialog; closing it returns focus to the trigger.
+
+## Design Boundary
+
+This is a preserve-mode operational-dashboard refinement. It keeps the existing dependency-free HTML/CSS, light
+neutral theme, single blue accent and rounded component language. No marketing-page imagery, glass effects, new font
+dependency or frontend framework was introduced.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-daily-upload-filename-provenance.md b/.project-docs/50-evidence/topics/2026-07-30-daily-upload-filename-provenance.md
new file mode 100644
index 0000000..2b06b96
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-daily-upload-filename-provenance.md
@@ -0,0 +1,53 @@
+# Daily Upload Filename Provenance
+
+## Status
+
+Implemented, migrated and loaded in the authenticated port-8766 runtime; one no-PII live write-path check remains.
+
+## Problem
+
+The browser submitted the selected XML basename, but `ProgrammaticUploadCoordinator` normalized the source object to
+`source.xml` and did not persist the browser value. Daily history and task trace then read
+`ingestion.artifacts.original_filename`, exposing the internal canonical artifact name as if it were the uploaded name.
+
+The existing 24 July history rows therefore cannot recover their original client filenames from current database
+facts. Guessing from business dates or object names would create false provenance.
+
+## Implemented Boundary
+
+- Added and applied migration `013_daily_upload_filename.sql`.
+- Added nullable `ingestion.processing_runs.uploaded_filename` with basename, control-character, length and XML-suffix
+  checks.
+- The active and legacy upload coordinators pass the already validated browser filename through `JobRegistration`.
+- `PostgresIngestionRepository` stores and identity-checks that value when registering a run.
+- Daily history and task trace now select `run.uploaded_filename`.
+- The frontend renders `—` when historical provenance is absent.
+- Source objects, processor input and independent validation continue to use canonical `source.xml`.
+- The down migration refuses to discard the column after any upload provenance has been recorded.
+
+## Database Evidence
+
+- Migration SHA-256:
+  `f7ea18d6b844d9bd90fa757a4cf8428d1dd5833c088ae23444ac81fbe204fb0a`.
+- Down-migration SHA-256:
+  `663d287e36bb99533d28918d8ead7a2b03e8bbf7e7d682a527400d11f0ee3fbf`.
+- Post-migration state: 25 processing runs, zero non-null uploaded filenames, validated
+  `processing_runs_uploaded_filename_check`.
+- July repository state: 24 history rows, 24 unknown uploaded filenames, zero internal `source.xml` values exposed by
+  the new query.
+- A transaction-only probe used synthetic basename `歌剧院-0723.XML`; both history and trace returned that value while
+  the source artifact remained `source.xml`. The transaction was rolled back and left no probe data.
+
+## Automated Evidence
+
+- 69 focused migration, programmatic-ingestion, repository, trace, direct-ingestion and Web tests passed.
+- JavaScript syntax and changed Python module compilation passed.
+- Full discovery passed 312 tests in 99.280 seconds with 10 environment/fixture skips and no failures or errors.
+
+## Runtime Activation Boundary
+
+The former 17:13 process predated both this change and application login. It later exited, and the replacement runtime
+now starts with Keychain-backed Web/OSS credentials, reports all five readiness flags true and serves authenticated
+`/api/jobs` with the existing total of 24. Activation intentionally submitted no XML, so one controlled no-PII upload
+must still verify that a new browser basename appears in live history/trace while the internal artifact remains
+`source.xml`.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-first-live-arr2-user-run.md b/.project-docs/50-evidence/topics/2026-07-30-first-live-arr2-user-run.md
new file mode 100644
index 0000000..c591392
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-first-live-arr2-user-run.md
@@ -0,0 +1,69 @@
+# Evidence Topic: First live ARR2 user-run trace
+
+## Metadata
+
+- Date: 2026-07-30
+- Status: Resolved by the monthly-publication implementation completed later on 2026-07-30
+- Scope: ARR2 loopback runtime on port 8766, controlled `booking_test`, OSS-backed daily processing, local monthly output
+- Confidence: Fact
+- Source: persisted Web API trace, read-only PostgreSQL queries, local artifact metadata, source inspection
+- Last verified: 2026-07-30
+- Stale trigger: Historical evidence; see `2026-07-30-monthly-publication-live-acceptance.md` for current behavior
+
+## Question
+
+Did the user's successful daily run commit to PostgreSQL, and why did the subsequent successful monthly action show no
+monthly result or download?
+
+## Evidence
+
+### Daily commit
+
+- Job `arrjob-e6042c9cfe7d4f07aa986c5cffc4548e` is processing run 27 with terminal raw status `accepted`.
+- Its one artifact delivery is committed and points to Finance daily version 4.
+- Daily version 4 is `active`, version number 1, for business date 2026-07-21.
+- Counts reconcile: 135 source rows = 119 retained + 16 excluded-rate-code; duplicate, validation-failed and
+  price-unmatched counts are zero.
+- `finance.current_daily_versions` points 2026-07-21 to daily version 4.
+- The trace contains `ARTIFACT_RESULT_COMMITTED`, `FINANCE_VERSION_ACTIVATED` and `JOB_SUCCEEDED` terminal evidence.
+
+### Downstream event
+
+- The same database transaction created `arr.daily_version_committed` for processing run 27.
+- At verification time it was `pending`, with zero publish attempts and no published timestamp.
+- This is consistent with the documented absence of an automatic monthly outbox consumer.
+
+### Monthly generation and visibility
+
+- The manual monthly request generated `outputs/monthly_reports/2026/07/latest.xlsx` and `latest.result.json` at
+  2026-07-30 14:28:32 +08:00.
+- Result status is `success`; the workbook SHA-256 is
+  `57160ff45d962ef348d7e9d21eafdc1f89c8b7b00d9d31eba283c2ff6bc40410`.
+- The workbook reopens and contains LIANTAI-GROUP 26 rows, LIANTAI-FIT 25, QBD 55, DY-AI-Easy-KB 0 and FENGRUN 14:
+  120 data rows total. No guest values were recorded in this evidence.
+- The workbook contains zero formulas, matching the known formula implementation gap.
+- Read-only `information_schema` verification proved `finance.report_versions` is absent from `booking_test`.
+- `monthly_reports.repository.PostgresReportRepository.reserve_report()` creates only a deterministic in-memory
+  generation identity; `activate_report()` revalidates source pins but inserts no report metadata.
+- `arr_web.repository.MONTHLY_RUNS_SQL` synthesizes a `source_ready` row with null `report_id`, filename and artifact
+  hash. The frontend only renders a download link when an artifact hash/report ID is present.
+
+## Finding
+
+The daily success is a real, atomic PostgreSQL commit. The monthly action also produced a real local XLSX, but its
+success is not persisted as a monthly report version and cannot be rediscovered or downloaded through the Web API.
+The frontend toast therefore overstates the end-to-end outcome: it confirms local file generation, not published report
+visibility.
+
+## Impact
+
+- Daily ingestion does not need repair for this run.
+- A monthly publication task must define and implement a database-compatible metadata/read model (or an equally durable
+  controlled artifact index), then align list/download APIs and the success receipt with that persisted state.
+- Automatic dispatch remains separate: the accepted outbox event currently has no consumer.
+- The required `TOTAL PRICE` formula remains a separate workbook-generation defect.
+
+## Open Items
+
+All three items were resolved later on 2026-07-30 by migration 012, the dedicated worker, persisted list/download
+identity and formula validation. This topic remains the before-state diagnosis.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-monthly-publication-live-acceptance.md b/.project-docs/50-evidence/topics/2026-07-30-monthly-publication-live-acceptance.md
new file mode 100644
index 0000000..0b60e7f
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-monthly-publication-live-acceptance.md
@@ -0,0 +1,89 @@
+# Evidence Topic: Monthly publication live acceptance
+
+## Metadata
+
+- Date: 2026-07-30
+- Status: Active
+- Scope: migration 012, dedicated worker, controlled `booking_test`, local monthly artifacts and ARR2 loopback Web
+- Confidence: Fact
+- Source: migration rollback probes, automated tests, read-only PostgreSQL queries, workbook reopen inspection and live Web API/download checks
+- Last verified: 2026-07-30
+- Stale trigger: migration/report schema, formula layout, output storage, worker acknowledgement or Web download behavior changes
+
+## Question
+
+Does a successful daily commit now produce a durable, visible and downloadable monthly report with the correct
+ARRIVAL watermark and `TOTAL PRICE` formulas?
+
+## Evidence
+
+### Migration and publication state
+
+- `database/012_monthly_report_publication.sql` was executed inside an uncommitted up/down probe, then in a synthetic
+  active-publication probe, and both left no objects/data after rollback.
+- A privacy-minimized pre-migration checkpoint was created at
+  `runtime/backups/booking_test_pre_012_20260730T151922+0800/manifest.json`; its SHA-256 is
+  `230206eb074fd5877132744eb592b3dc6cd6638d12a3dd4d71a8101a9bd2998e`.
+- Migration 012 was then applied to `booking_test`. It adds three metadata-only `reporting` tables and permits controlled
+  `local` monthly artifacts; 008–011 were not edited or replayed.
+- Current read-only state is 2 Finance daily versions, 120 active facts, one active monthly run, one local
+  `monthly_xlsx`, one local `result_json`, and two published daily-commit events.
+
+### ARRIVAL derivation and idempotency
+
+- Event 3 referenced daily version 2 with retained `ARRIVAL=2026-07-27`; event 9 referenced daily version 4 with retained
+  `ARRIVAL=2026-07-21`.
+- The worker looked up those facts by `daily_version_id`, ignored payload dates/filenames, and selected the July scope.
+- Both events resolved to report ID 1/version 1 with `as_of_date=2026-07-27`, exactly the maximum `ARRIVAL` in the
+  current 120-row monthly snapshot. The second event reused the published snapshot instead of creating a duplicate.
+- Both events became `published` after one attempt and only after the report and its two artifacts were registered.
+
+### Workbook and Web
+
+- The active report contains 120 rows across five worksheets: LIANTAI-GROUP 26, LIANTAI-FIT 25, QBD 55,
+  DY-AI-Easy-KB 0 and FENGRUN 14.
+- Reopening the real workbook found exactly 120 formulas, all in the `TOTAL PRICE` data cells and all equal to the
+  expected row-relative `=R[row]*C[row]*G[row]`; there were zero missing, mismatched or unauthorized formulas.
+- The workbook is 20,741 bytes with SHA-256
+  `a43cdac3f6d97f6b73f635d0f386cffd0ad8b6416e8f92a7b6afc2f79f1e8635`.
+- `/api/monthly-runs?month=2026-07` returns the active database report. `/api/download/monthly?report_id=1` returned
+  bytes matching the registered size and hash.
+- The primary page no longer contains month, cutoff or manual-generate controls and explains the max-ARRIVAL policy.
+- The final full discovery passed 291 tests in 108.2 seconds with 7 explained environment/fixture skips and no
+  failures or errors; this included real monthly XLSX generation/reopen coverage using the configured builder runtime.
+
+### Later live versions and automatic page discovery
+
+- Two later user uploads advanced the live publication history to V03 active and V02/V01 superseded. All four
+  daily-commit events are published.
+- V03 contains 308 rows across six channels. Its persisted `as_of_date` and max current fact `ARRIVAL` are both
+  2026-07-27.
+- An independent reopen of V03 found exactly 308 expected `TOTAL PRICE` formulas and zero bad formulas. The file is
+  40,635 bytes with SHA-256 `494fb78283f68cb7bffce2501b3531613c494d148a56a7ccc8d2aabe46b1e29c`, matching its registered artifact.
+- The monthly page was changed to remove its refresh button and poll only while visible/active. Browser inspection
+  rendered V03/V02/V01 with download links and `自动更新 · 4 秒`; Web access logs showed repeated
+  `/api/monthly-runs` reads at the intended cadence without a click or reload.
+- A subsequent 07-23 upload committed daily version 7 with 109 retained rows and advanced publication to V04 active
+  with 417 rows. All five daily-commit events are published; V03/V02/V01 remain superseded history.
+
+### Data-quality caveat discovered after publication
+
+- A later read-only Channel BI reconciliation proved that Finance version 2 in every July snapshot is the database
+  acceptance run `mvp-v1-fixture-20260727`, sourced from `synthetic.xml` with provider `local_fixture`.
+- Publication, lineage, formula and download mechanics remain verified, but V01/V02/V03/V04 are not clean business-data
+  acceptance artifacts: the fixture adds one row and makes 2026-07-27 the maximum included ARRIVAL.
+- See [Channel BI post-update data contamination](2026-07-30-channel-bi-post-update-data-contamination.md) for the
+  measured impact and repair boundary.
+
+## Finding
+
+The original publication-identity discrepancy is resolved in the controlled local runtime: monthly success now means a persisted report
+identity with registered, hash-checked downloadable bytes. The “更新至” label is based on the latest included
+`ARRIVAL`, formulas are present and verified, automatic dispatch is performed by a separate durable worker, and the
+open monthly page discovers the published version without manual refresh. Current July business values remain subject
+to the fixture-contamination caveat above until controlled repair and republication.
+
+## Remaining Boundary
+
+The workstation already runs Web and worker separately. Production Compose packaging must still provide the required
+Node/artifact-tool runtime and shared output volume before enabling an equivalent managed worker service.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-superagent-fetch-oss-prompt-experiment.md b/.project-docs/50-evidence/topics/2026-07-30-superagent-fetch-oss-prompt-experiment.md
new file mode 100644
index 0000000..fee128c
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-superagent-fetch-oss-prompt-experiment.md
@@ -0,0 +1,205 @@
+# SuperAgent fetch_oss_file Prompt Experiment
+
+## Scope
+
+Use the first fresh controlled ARR trace together with historical successful
+SuperAgent fetches to separate Agent orchestration behavior, address shape and
+platform file-type policy.
+
+## Evidence
+
+- The controlled ARR job reached the intended SuperAgent Agent with its
+  generated `PROGRAM_INPUT` and invoked `fetch_oss_file` four times.
+- The trace shows that the runtime Tool accepted `object_uri` and optional
+  `filename` arguments. The first OSS shorthand request returned
+  `public_endpoint_missing`; later HTTPS variants for the XML returned
+  `extension_not_allowed`. These are Provider results, not Tool Schema
+  validation failures.
+- The Agent also read the Skill and used shell inspection before fetch success,
+  despite the prior concise Prompt's stop rule. The four calls were four Agent
+  tool decisions inside one remote run, not four ARR submissions or transport
+  retries.
+- Historical exported conversations contain successful `fetch_oss_file` calls
+  for `.xlsx` attachments. They returned a local file under the runtime uploads
+  directory in about 2–20 seconds. Those inputs included a direct OSS HTTPS URL,
+  but the exports do not expose the raw Tool Call arguments and do not prove
+  that `.xml` is allowed in the ARR Agent's Provider configuration.
+- No public upstream source or official documentation containing the two
+  Provider error codes was found; this appears to be a platform-specific Tool.
+
+## Historical Prompt Change (superseded)
+
+The first corrective Main Prompt made fetch the only permitted tool action before a
+local source file exists and specifies exactly one call:
+
+```text
+object_uri = "oss://" + attachment.oss.bucket + "/" + attachment.oss.object_key
+filename   = attachment.name
+```
+
+It forbids parallel Skill/reference reads, filesystem/environment inspection,
+URI substitution, argument repair, shell/curl/SDK fallback and every second
+fetch. Any Provider failure, ambiguous result or missing unique local path must
+produce one failed Profile object and end the run.
+
+The Prompt is 1,757 characters, remains below the existing 1,800-character
+regression limit, and all 12 `tests.test_arr_opera_daily_ingest` tests pass.
+
+## Interpretation at that stage
+
+This was a bounded diagnostic Prompt experiment. It removed retries and
+side-path noise but still used the wrong `oss://` address form.
+
+After this Prompt is published, one fresh controlled ARR job has decisive
+outcomes:
+
+1. One successful fetch returning one local XML path: continue to verify the
+   Skill and MCP stages.
+2. One `public_endpoint_missing`: confirm whether the Tool expects a public
+   HTTPS address before assuming it needs a credential-backed Provider.
+3. One `extension_not_allowed`: add `.xml`/`application/xml` to the Provider's
+   allowed source types; do not keep tuning the Prompt.
+4. More than one fetch or any pre-fetch shell/Skill read: the published Prompt
+   was not selected or the model did not obey it; verify Profile version and
+   runtime trace before any Provider change.
+
+## Published Prompt Follow-up Run
+
+Controlled job `arrjob-737acd9d35d04d7f9bcbbc60e7674236` reached remote run
+`53510877-e30a-4f44-9a6c-26980cdc9461` with the intended PROGRAM_INPUT. The
+available trace snapshot shows:
+
+- exactly one model-selected tool action: `fetch_oss_file` with `object_uri`
+  and `filename=source.xml`;
+- no `read_file`, shell, Skill processor, `present_files`, or
+  `arr_submit_processing_result` action before or after that fetch in the
+  captured events;
+- one Provider result with `success=false`,
+  `error=public_endpoint_missing`, and message
+  `Public OSS endpoint is not configured`;
+- no HTTPS substitution and therefore no new `extension_not_allowed` result.
+
+This validates the Prompt's single-call/no-bypass behavior for the captured
+portion and makes the Agent-specific Provider endpoint the first actionable
+blocker. The supplied snapshot was refreshed about 20 seconds after the Tool
+result and still ended with `status=running`; it contains no `message.final` or
+terminal run event, so it does not yet prove final failure-object emission or
+ARR terminalization. A later trace snapshot is required for that separate
+check.
+
+## Public-read URL correction
+
+The user subsequently confirmed two authoritative deployment facts:
+
+- `fetch_oss_file` reads a publicly accessible OSS URL and does not need a Provider;
+- the ARR OSS deployment is `public-read`.
+
+The local implementation therefore replaces the historical `oss://` experiment:
+
+- PROGRAM_INPUT is now `arr-opera-daily-program-input-3` with required `oss.url`;
+- ARR generates `https://{bucket}.oss-{region}.aliyuncs.com/{encoded-object-key}` with no query signature;
+- the 1,682-character Prompt copies `oss.url` to `object_uri` and `source.xml` to `filename` exactly once;
+- only committed source XML has public-read object ACL; staged objects and processing outputs remain private;
+- OSS readiness requires the approved public-read bucket.
+
+Forty-four targeted tests and the complete 276-test suite pass with two expected skips. No fresh platform run
+has exercised this contract yet. The next controlled trace should no longer return `public_endpoint_missing`.
+If it returns the previously observed `extension_not_allowed`, `.xml`/`application/xml` must be enabled in the
+platform Tool; more Prompt retries or URI substitutions would not solve that policy failure.
+
+## Mixed-version local run
+
+The next attempted local job, `arrjob-1fd0f5e182eb4056af53be7300174981`, did not exercise this correction.
+Its complete available snapshot ended about seven seconds after `run.started`, still in middleware processing,
+with no `object_uri`, Tool call, Tool result, Skill load, MCP submission or terminal event. The serialized input
+was PROGRAM_INPUT v2 because the local 8765 process had started before the v3 files were modified. After the
+validated listener was restarted, all local readiness flags returned true. Acceptance therefore moves to one
+new job generated by the restarted process; the mixed-version job must not be used as v3 evidence.
+
+## First true v3 controlled run
+
+Job `arrjob-3c2cd75568554b0ab6d17817d446fed1` provided decisive evidence:
+
+- middleware input contained `arr-opera-daily-program-input-3` and no v2 contract;
+- the model selected exactly one `fetch_oss_file` call with a redacted URL and `filename=source.xml`;
+- the Tool returned `success=false`, `error=extension_not_allowed` and no artifact;
+- the Agent made no second fetch, shell/SDK bypass, Skill call or MCP submission;
+- SuperAgent emitted `run.completed` with remote status `success`, meaning the orchestration run ended, not that business ingestion succeeded;
+- direct submissions, deliveries, Finance versions and outbox events remained zero.
+
+Because an extension check can precede network download, ARR separately issued an anonymous HEAD without
+logging the object URL or reading its body. OSS returned HTTP 200, `Content-Type: application/xml` and
+`Content-Length: 629434`, exactly matching the registered source. ARR URL generation, object ACL and public
+reachability are therefore verified; the platform XML allowlist is the sole current fetch blocker.
+
+The failure path also exposed two independent defects. The Agent returned only `job_id`, `source_file_id`, a
+minimal `processor_result` and `files=[]`, which does not satisfy the Profile output object's required fields
+or object-shaped `files`. ARR still reported `status=running` 145 seconds after `run.completed`, because no MCP
+submission arrived and remote finalization is not yet projected into a terminal ARR failure. Neither defect
+should be addressed by URI retries or renaming the XML.
+
+## Frozen RUNNING diagnosis
+
+A later read-only inspection distinguished a stale ARR state from an active Agent run:
+
+- the page continued polling every four seconds and its `refreshed_at` changed, but two trace samples stayed at 73 events;
+- the last persisted Agent event was `AGENT_TRACE_STREAM_ENDED` at 11:42:26 +08, three seconds after remote `run.completed`;
+- a read-only remote run lookup reported `success`, while the authoritative ARR run and attempt remained `running` with no finish time;
+- the trace JSONL was fully drained and no longer changing; direct submissions, artifact deliveries, Finance versions and outbox events were all zero;
+- the Agent-side business result was a safe failure code, `extension_not_allowed`, not an in-progress computation.
+
+The production upload path constructs `ProcessingRunner` but calls only `start()`. No production component invokes
+`poll()` or otherwise reconciles a terminal remote run that never calls MCP. Even if polling were wired, the runner's
+`delivery_missing` transition is not recognized by `PostgresProcessingState`. The available stale-expiry helper is
+also not scheduled and applies to existing submission rows, whereas this run created none. Consequently this task
+will remain `running` until separately authorized repair logic or a guarded one-off repair is applied. Diagnosis did
+not change state, retry the stream, submit another XML or restart a service.
+
+## Main-flow impact assessment
+
+The stale `running` state is not currently a report-generation gate:
+
+- every upload creates a new independent job id, and the upload control is gated by runtime readiness rather than another job's status;
+- Finance facts are written only inside an accepted MCP submission transaction, not by polling or task-log state;
+- current monthly and analytics readers consume committed Finance facts and do not wait for all processing jobs to become terminal;
+- at this historical ARR1 snapshot, automatic post-commit monthly dispatch was still absent, so no worker was blocked by this stale job. ARR2 later implemented a separate worker under ADR-001.
+
+Terminal reconciliation would still improve operational correctness: accurate failure state, bounded retry semantics,
+idempotent failure notification and earlier revocation of an unused writeback grant. It does not make a failed XML run
+produce Finance facts or a report. At that snapshot, the immediate business-flow priorities were platform XML
+allowlisting, a successful MCP commit and the accepted automatic report-trigger implementation; the later run below
+supersedes the allowlist diagnosis while leaving the reconciler as separate control-plane hardening.
+
+## First successful fetch/process and rejected direct submission
+
+Job `arrjob-ae1a40b69c46402fb80e55c980b84886` proves that the platform XML allowlist was changed successfully and moves
+the failure boundary beyond OSS ingestion:
+
+- PROGRAM_INPUT v3 caused exactly one `fetch_oss_file` call. The Tool returned `/mnt/user-data/uploads/source.xml`,
+  `application/xml`, 629434 bytes and SHA-256
+  `b0019ba8ecdaad878bbde463b0c3129994bf582da16d9caf56d6b96da9a2db2a`, exactly matching ARR registration.
+- The Agent loaded the intended Skill and references and invoked `process_daily.py` once. Its successful result reported
+  135 source rows, 16 excluded rows, 119 output rows and channel counts 26/25/54/14.
+- The generated `structured-result.json` passed the Agent's own key check with 135 records, zero errors and a compact
+  size of 124493 characters.
+- Instead of passing that object through once, the Agent repeatedly reopened, printed, compacted and range-read the
+  file. The captured sequence includes extra shell inspections, a temporary serialization, a full-file read and a
+  later `start_line=1200,end_line=2500` read.
+- The supplied trace snapshot was not a terminal log: it was copied at 12:41:02 +08 with `status=running`, after the
+  trace connection had failed at 12:40:15 +08. A later read-only ARR trace projection shows the actual terminal state
+  at 12:41:40 +08.
+- Exactly one MCP submission then reached ARR, proving active Tool discovery, network reachability and grant acceptance.
+  ARR recorded only 20 submitted records, rejected the request as `RESULT_CONTRACT_INVALID`, wrote no Finance version,
+  terminalized the job as failed and queued one downstream failure event.
+
+The submitted payload therefore was not the complete 135-record object produced by the deterministic Skill. This is
+not an OSS, XML allowlist, Skill or MCP reachability problem, and another URI or fetch retry cannot change it. The
+current contract makes the model reproduce roughly 124 KB of JSON inside one tool-call argument after receiving that
+file through bounded model/tool context. Prompt wording can discourage the observed rereads, but cannot make exact
+large-payload transport reliable.
+
+The durable options are either (1) let an installed platform-side submission adapter consume a sandbox file without
+serializing it through model output, or (2) make the MCP call a compact attempt-bound completion signal and let ARR use
+the deterministic source replay it already performs as the canonical structured payload. A remote MCP server cannot
+resolve an Agent-local `/mnt/...` path by itself, so merely replacing `payload` with `payload_path` in the current
+remote schema would not work.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-task-log-relocation-and-scope.md b/.project-docs/50-evidence/topics/2026-07-30-task-log-relocation-and-scope.md
new file mode 100644
index 0000000..1bc9a57
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-task-log-relocation-and-scope.md
@@ -0,0 +1,50 @@
+# Task-Log Relocation and Daily-Only Scope
+
+## Metadata
+
+- Date: 2026-07-30
+- Status: Historical before-state; superseded by the 2026-07-31 upload interaction update
+- Scope: Desktop task-log information architecture, interaction and backend query boundary
+- Confidence: Fact
+- Source: `arr_web/static/`, `arr_web/repository.py`, focused tests and live local browser verification
+- Last verified: 2026-07-30
+- Stale trigger: The header utility, trace API/query, processing pipeline type, or task-console selection/polling lifecycle changes
+
+## User-Facing Outcome
+
+- The desktop header position formerly occupied by `手机看板` now contains a `任务日志` button.
+- The sole existing black task console moved out of the 日报处理 panel into a native modal dialog; no duplicate console or alternate trace implementation was introduced.
+- Before the 2026-07-31 upload interaction update, starting an upload opened the dialog. Mouse or keyboard activation
+  of a daily-history row still opens the selected job's trace.
+- Closing the dialog returns focus to the opener. Trace polling runs only while the dialog is open and the selected job remains active.
+- The `/h5` route and assets remain intact, but the desktop header no longer links to them.
+
+## Scope Proof
+
+- `JOBS_SQL`, `JOBS_COUNT_SQL` and `JOB_TRACE_RUN_SQL` each explicitly require
+  `run.pipeline_type = 'opera_daily'`.
+- `GET /api/jobs/{job_id}/trace` resolves exactly one validated job ID.
+- `get_job_trace` reads that run's processing attempts, artifact deliveries or legacy direct submissions, linked
+  Finance daily versions and processing-run outbox events, then sends those allowlisted facts to `build_job_trace`.
+- The console therefore represents one daily XML-processing job across upload, processor, validation, database and
+  linked downstream-notification stages. It is not a global application/server log and does not enumerate other daily
+  jobs, reporting monthly-run histories or company-report jobs.
+
+## Verification
+
+- `node --check arr_web/static/app.js`: passed.
+- Focused unit set: 12 passed across task-log UI contracts, repository SQL/privacy contracts and trace projection.
+- At 1440×900, the live modal measured 1040×692, remained centered, had no page horizontal overflow and rendered a
+  current 13-event terminal. Closing returned focus to `#task-log-trigger`.
+- Activating a different daily-history row selected the matching job and loaded `SUCCEEDED`, `13 events` and an `END`
+  terminal line.
+- At 375×812, the modal measured 355×792 with 10-pixel margins. The header trigger remained displayed; document and
+  dialog horizontal-overflow checks were both false, with long raw lines confined to the terminal scroller.
+- Browser warning/error count was zero.
+
+## Known Verification Boundary
+
+The broader `tests.test_arr_web` class currently issues anonymous requests while separate in-progress login work has
+already changed the application to return redirects/401 responses. That class reported 13 authentication-boundary
+failures/errors before reaching the relocation assertions. Authentication files were left untouched; task-specific
+static contracts were isolated in `tests/test_arr_web_task_log_ui.py`.
diff --git a/.project-docs/50-evidence/topics/2026-07-30-web-login-runtime-mismatch.md b/.project-docs/50-evidence/topics/2026-07-30-web-login-runtime-mismatch.md
new file mode 100644
index 0000000..db36d0c
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-30-web-login-runtime-mismatch.md
@@ -0,0 +1,77 @@
+# Web Login Runtime Mismatch
+
+## Metadata
+
+- Date: 2026-07-30
+- Status: Resolved; authenticated live runtime verified
+- Scope: Live ARR Web process on port 8766
+- Confidence: Fact
+- Source: Listener/process inspection, Keychain/launcher validation, loopback/LAN HTTP probes, source/config inspection, automated tests and isolated browser verification
+- Last verified: 2026-07-31
+- Stale trigger: Port-8766 process restart, login credential configuration, route/config change or LAN-address change
+
+## Finding
+
+The first page-failure check found a partial runtime mismatch: PID 37865 was still bound to `0.0.0.0:8766` and returned
+HTTP 200 for `/`, `/h5`, `/api/health` and `/assets/app.js` on loopback and the then-current LAN address
+`192.168.3.48`, while `/login` alone returned 404. The long-lived Python process predated the new login routes but read
+newer static files from disk.
+
+The 23:03 recheck found a complete service outage instead. No process listens on 8766, PID 37865 is gone, and `/`,
+`/h5`, `/login`, `/healthz`, `/api/health` and `/assets/styles.css` all fail with connection refused on loopback. The
+workstation's Wi-Fi address has also changed to `192.168.3.103`, where root and login probes likewise fail. No matching
+Web/worker/ngrok process, 8765/8766/8877 listener or launch agent was found. There is no retained process log, so the
+evidence does not prove why PID 37865 exited.
+
+The login change was nevertheless relevant to recovery: current source intentionally refuses startup when
+`ARR_WEB_USERNAME` or `ARR_WEB_PASSWORD` is absent. After the operator supplied both values, each was placed in a
+separate macOS Keychain item and a credential-free local launcher restored the prior controlled database, OSS,
+processing, monthly and company-report inputs. Detached Screen session `arr2-web-8766` owns the sole `*:8766` listener.
+
+## Completed Source Implementation
+
+- `arr_web.auth` validates runtime `ARR_WEB_USERNAME` / `ARR_WEB_PASSWORD` with constant-time comparison and a bounded
+  per-client attempt ledger. Missing/invalid configuration fails Web startup closed.
+- The authenticated `SessionLedger` issues random server-side sessions and per-session CSRF tokens, expires/revokes
+  them and retains `HttpOnly`, `SameSite=Strict` plus optional `Secure` cookie behavior.
+- Login assets, `POST /api/login` and minimal `/healthz` are the only anonymous routes. Desktop/H5 documents redirect
+  to an allowlisted login target; all business APIs, detailed health, uploads, traces and downloads reject anonymous access.
+- Desktop and H5 clients redirect expired sessions to login and expose CSRF logout. Logout starts hidden when an old
+  anonymous `/api/session` response lacks `username`; this kept PID 37865's mixed-version UI compatible before activation.
+- Caddy Basic Auth was removed; Caddy remains the HTTPS boundary and the Web application owns human authentication.
+
+## Verification
+
+- Python and all three JavaScript files pass syntax checks.
+- The final focused auth/Web/company/deployment set passes 33 tests. Full discovery passes 317 tests in 97.311 seconds
+  with 10 expected environment/fixture skips and no failures/errors.
+- Isolated real-browser verification passed generic wrong-credential feedback, password visibility, successful desktop
+  and `/h5` return, desktop/H5 logout, zero console warnings/errors and no horizontal overflow at 375, 768, 1024 and
+  1440 CSS pixels.
+- Ruby's standard YAML parser loaded the Compose file and deployment contracts confirm login env/readiness/Caddy
+  shape. Docker is unavailable on this workstation, so no live Compose expansion or image build is claimed.
+- Live activation passes anonymous root 303 and business API 401; exact login, hardened cookie/session/CSRF,
+  authenticated desktop/H5/history reads, all five readiness flags, logout and post-logout rejection. Loopback and
+  `192.168.3.103` return `/healthz` 200; LAN root redirects to login and LAN `/login` returns 200.
+- A 2026-07-31 follow-up found the service still healthy under the same detached Screen process, while DHCP had moved
+  the workstation address back to `192.168.3.48`. Loopback and `.48` root return 303 to login, `/healthz` and the login
+  document return 200, and the in-app browser rendered the complete login gateway. The prior `.103` URL is now stale.
+- The same-day visual follow-up removed the login page's workflow narrative, numbered steps, duplicate brand placement,
+  welcome/access introduction and support disclaimers. The live gateway now presents one `ARR Report` heading, the
+  `username` and `password` fields, password visibility and the existing submit/error states. Thirty-three focused
+  Web/auth/UI tests pass; browser checks at 390x844 and 1280x720 have no horizontal overflow or console warnings/errors.
+- Read-only totals remained 24 daily jobs, 4 monthly versions and 3 company jobs. No XML upload, company-report job,
+  monthly worker or other business mutation was started during activation.
+
+## Activation Result and Operating Boundary
+
+- Current LAN entry as of 2026-07-31: `http://192.168.3.48:8766/`; DHCP may change this address again.
+- `/Users/chillishark/.local/bin/arr2-web-8766` is owner-executable only and contains paths/Keychain labels, not secret
+  values. Its `--check` mode validates required private inputs without printing them.
+- The active Screen session is detached but not a reboot-persistent process manager. A future controlled restart should
+  validate the launcher, stop the exact 8766 listener and start one `screen -dmS arr2-web-8766` session.
+- Credential rotation to a password distinct from the public username is recommended. Update the Keychain item and
+  restart once; never commit the value or put it in process arguments.
+
+Live login/readiness is complete. A no-PII upload-filename check and one released 5/5 company-report job remain separate
+business-mutation acceptance work.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-booking-extraction-program.md b/.project-docs/50-evidence/topics/2026-07-31-booking-extraction-program.md
new file mode 100644
index 0000000..65dc5d9
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-booking-extraction-program.md
@@ -0,0 +1,84 @@
+# Evidence Topic: Booking Excel extraction program
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Implemented, migrated and runtime-active; first real source activation pending
+- Scope: Raw Tour Code/`โรงแรม` XLSX parsing, editable/batch-deletable review drafts, HTTP transport and current-source activation
+- Confidence: Fact
+- Source: supplied workbook, deterministic parser output, source/migration inspection, unit/integration tests, browser QA, live database transaction probe and authenticated runtime probe
+- Last verified: 2026-07-31 12:43 +08:00
+- Stale trigger: parser/review API change, migration change or first real draft activation
+
+## Question
+
+Does ARR2.0 now contain the requested program that extracts Tour Code plus one or more room-type/quantity rows, keeps
+uncertain items for human correction/deletion and activates only a fully reviewed workbook?
+
+## Evidence
+
+- The parser reads only worksheets containing Tour Code plus exact `โรงแรม`, strips Tour Code whitespace,
+  uses physically-last-row replacement/cancellation semantics and does not infer cancellation from yellow fill.
+- Parenthesized `【label】 quantity` items are split in source order. Missing outside quantity defaults to one. Numeric
+  variants normalize to `U-TWN`/`U-DBL`; `高级房TWN`/`高级房DBL` normalize to `TWN`/`DBL`. Unknown or unbracketed room
+  names preserve raw text and parsed/default quantity as pending.
+- A direct parse of the supplied `ai样板.xlsx` found one source sheet, 26 distinct Tour Codes, 37 room items and 7
+  pending items. Confirmed quantity is 198; all extracted quantities including pending are 208.
+- Supplied examples replay exactly: `LLT260715AC` becomes separate `TWN / 12` and `DBL / 9` items;
+  `LT260715LD` becomes confirmed `U-TWN / 17` plus pending raw `U-เตียงเสริม13 / 2`.
+- Migration 015 stores confirmed/pending/deleted item state outside canonical Booking facts. Repository activation rejects
+  pending or empty drafts, groups confirmed items back into source rows, creates immutable accepted facts and switches
+  `booking.current_source_batch` in the same transaction.
+- The desktop review table exposes Tour Code, raw label, editable room type/quantity, status, save and delete. Pending
+  items are visually separated and excluded until saved; discard and whole-draft activation are explicit operations.
+- Review pages are fixed at 50 records. Individual and all-visible checkboxes feed a strict 1-50-unique-ID batch route;
+  the PostgreSQL repository soft-deletes the entire set under one advisory-locked transaction and rolls back if any
+  selected item is missing, already deleted, foreign to the draft or no longer reviewing. The old item route remains.
+- Single and batch delete use one native in-page modal with count-aware copy, focus containment/restoration, backdrop,
+  button and explicit Escape cancellation. Isolated browser QA selected one, two and all 50 visible rows, proved no
+  browser-native prompt appeared and did not invoke the confirm action or any deletion endpoint.
+- The upload helper, review kicker/description/guidance and source counts/time were removed as requested. The later
+  filename-context follow-up removed the separate accepted/historical-source status and summary completely. The review
+  heading is `Booking记录提取`, with `summary.filename` directly below it as the provenance for the displayed records.
+- `arr_web.server` now forwards POST/PATCH/DELETE through the same bounded request-body gate. Real loopback socket tests
+  cover PATCH and DELETE forwarding, security headers, missing length and oversized requests.
+- Migrations 014/015 were applied after a transaction-only migration probe. Current batch 1 retained 867 room items,
+  348 Group Codes and quantity 867; the review tables remained empty after application.
+- A real-PostgreSQL transaction-only vertical slice created a two-item draft with one pending item, edited that item to
+  `EXTRA BED`, activated one immutable source row with quantity three, then rolled back the outer transaction. Batch 1
+  remained current and zero synthetic drafts/artifacts remained.
+- Port 8766 restarted at 12:43:37 +08 with the final review-filename source. Authenticated health reports database, processing,
+  monthly, download, company-report and company-source-upload readiness true; the draft endpoint is empty and logout succeeds.
+  Anonymous PATCH/DELETE reach the application and return JSON `401 AUTH_REQUIRED`, proving the old HTTP 501 gap is gone.
+- JavaScript syntax, 40 focused Web/review/router tests and repository commit/rollback tests passed. Final post-change
+  discovery ran 346 tests in 100.681 seconds: all passed, with 10 environment-dependent optional renderer/private-fixture skips.
+- The filename-context follow-up passed JavaScript/HTML checks, 36 focused Web/company/review/router tests and a clean
+  346-test discovery in 98.882 seconds with the same 10 expected skips. Isolated desktop and 390-pixel QA used a long
+  filename, found no horizontal overflow or console errors and submitted no upload, deletion or activation.
+- Authenticated isolated browser QA covered desktop and 390-pixel upload/review/generation layouts, pending-item
+  editing, 1/2/50-row selection, single/batch dialog cancellation, zero horizontal overflow and zero console errors.
+- An expanded Booking/company/real-XLSX suite passed 55/55. The live read-only July 31 snapshot supplied 417 Finance
+  facts, 27 current Booking items and 27 matched Group Code versions; the processor returned valid reports for all five
+  companies with row counts 138/139/1/33/3 and zero errors. Missing/unmatched Booking Room cells remained blank.
+- Company generation reads `booking.v_current_room_items` and `booking.v_group_room_item_summary` on every repeatable-
+  read snapshot, so a reviewed Booking activation is visible to the next report without re-importing Opera/Finance.
+- The Web release gate intentionally keeps July `21-month-end` closed until 2026-08-01 00:00 Asia/Bangkok. Earlier July
+  periods are open but currently contain zero rows, so they do not prove populated Booking Room enrichment.
+
+## Finding
+
+The requested extraction capability is implemented, migrated and active in the authenticated company-report page.
+Uncertain labels preserve their quantity and remain explicit, editable, removable draft records; they cannot silently
+enter accepted room counts. The HTTP and database transaction gaps found during the earlier audit are repaired. The
+company-channel processor is already ready for this workflow; the remaining boundary is activation of an actual
+operator workbook, not another enrichment-algorithm change.
+
+## Open Items
+
+- Perform one explicitly authorized real workbook upload -> review/edit/delete -> activation before replacing the current
+  business Booking source. Activation is complete-source replacement, so use a checkpointed complete workbook and a
+  restoration plan on the shared test database.
+- Run a released populated company-report period after activation. For current July month-end, wait until the Bangkok
+  release boundary or use an isolated coordinator/clock fixture.
+- Decide separately whether latest-state single-operator review is sufficient or reviewer/reason/revision history is
+  required.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-booking-import-dimensions-and-live-database.md b/.project-docs/50-evidence/topics/2026-07-31-booking-import-dimensions-and-live-database.md
new file mode 100644
index 0000000..1bfc307
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-booking-import-dimensions-and-live-database.md
@@ -0,0 +1,84 @@
+# Evidence Topic: Booking import dimensions and live database inventory
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Database findings active; intake implementation superseded by later manual-review audit
+- Scope: Controlled `booking_test`, checked-in Booking Excel/current-source path, current Finance and company-report projections
+- Confidence: Fact
+- Source: repeatable-read/read-only PostgreSQL queries, schema/view/importer/Web composition inspection, focused tests
+- Last verified: 2026-07-31 11:34 +08:00
+- Stale trigger: Booking source activation, Finance current-version change, runtime restart, or Booking join-contract change
+
+## Question
+
+What data is currently in the ARR database, and which dimensions must an operator provide when Booking data is manually
+imported for Group Code enrichment?
+
+## Evidence
+
+- Read-only snapshots between 10:44 and 10:49 +08:00 confirmed `booking_test` contains:
+  - Booking: one accepted fixture batch, 867 source rows/current parses/room items, and 348 Group Code summaries.
+  - Finance: five current daily versions, 475 immutable records, and 417 active retained facts; 58 records are
+    historical/non-current. The active 417 still include the known one-row `local_fixture` fact.
+  - Ingestion: 25 runs, 24 attempts, five deliveries, 42 artifacts and ten outbox events.
+  - Reporting: four monthly runs, 14 daily-version lineage rows and 22 channel-manifest rows.
+  - `condon`: 11 room-type reference rows and no owner/entitlement/usage rows; it is not the Booking enrichment source.
+- The only Booking batch is accepted `expected_fixture`/`md/1.0` from `RES_COMMENT_TYPE_OF_ROOM.md`: six worksheets,
+  867 rows, 348 distinct normalized Group Codes, four room types and total quantity 867.
+- Booking currently groups by normalized `group_code_key + room_type` and sums quantity. It then emits one room summary
+  per Group Code. Worksheet, source row, artifact and parse version remain provenance/version fields, not report join
+  dimensions.
+- Of 348 Group Codes, 122 have multiple source rows, none has multiple room types, and 23 appear in both
+  `DY-AI-Easy-KB` and `LIANTAI-FIT`. Those 23 are globally merged by the current view and none is used by the current
+  Finance projection.
+- Finance has 417 active retained rows: 121 rows/27 distinct Group Codes are matched, 285 rows have no Group Code, and
+  11 rows/two Group Codes are unmatched. The 29 nonblank Group Codes show no reuse across stay segment, company,
+  channel, business date or block in the current snapshot.
+- The company-report projection contains nonblank Booking enrichment for all 121 matched rows and blank room/quantity
+  values for all 285 missing-code plus 11 unmatched rows. This proves the accepted blank fallback in the database
+  projection without publishing a report.
+- During this audit the worktree parser changed from a normalized three-column workbook to processor 2.0.0. The latest
+  parser expects `Tour Code` (Group Code aliases accepted) plus `โรงแรม`/Hotel text, keeps the physically last row per
+  Tour Code, removes last-row cancellations, and derives one or more room-type/quantity items from the hotel text.
+  Known TWN/DBL families are confirmed and unknown labels are marked for review.
+- At 10:59 the earlier direct PostgreSQL importer and old Web fixtures were not aligned with parser 2.0. This was a
+  point-in-time observation superseded by the later draft repository/editor implementation described in the dedicated
+  manual-review audit.
+- The intended Excel path treats each workbook as a complete replacement source: it creates an immutable batch and
+  switches the singleton `booking.current_source_batch`; equal-content uploads are SHA-256 idempotent/reactivatable.
+- A later 11:19 read-only snapshot found migrations 014/015 structurally live. `current_source_batch` selects historical
+  batch 1 and both review draft tables are empty. The audit did not perform those concurrent migrations.
+- The port-8766 process started at 2026-07-30 23:19, before the new Booking composition, and has no hot reload. The current
+  HTTP adapter also lacks PATCH/DELETE even though the review editor requires them, so the end-to-end review path remains
+  unavailable despite the new database tables.
+- The latest focused parser/migration/review/Web suite runs 27/27, but it uses fake repositories/router calls and does not
+  exercise real PostgreSQL or the HTTP method adapter. No Booking import, draft activation or report publication was
+  performed by this audit.
+
+## Finding
+
+For the current data, `Group Code + room type + quantity` is a sufficient normalized Booking allocation model. It is the
+database/report dimension, not a currently stable statement about manual workbook headers. The necessary invariant is
+that a normalized Group Code globally identifies one room allocation across worksheets, companies and stay segments.
+If the same Group Code can represent different allocations by stay dates or channel, the Booking schema, current views
+and company-report join must also gain those dimensions.
+
+Parser 2.0 targets `Tour Code + Hotel` extraction. The separate review draft design is now present in source and live
+database; the later 11:30 runtime loads it with PATCH/DELETE and source-upload readiness. The first real draft activation
+remains controlled acceptance work. Existing blank/missing lookup behavior remains correct.
+
+## Impact
+
+- Keep `Group Code + room type + quantity` as the normalized result dimensions, while separately choosing and freezing
+  whether operators upload a normalized template or the real raw `Tour Code + Hotel` export.
+- Treat workbook/sheet/row/hash as audit provenance, not as hidden business keys.
+- Accept the parser/draft repository through a real PostgreSQL activation before claiming manual Excel import business-
+  write acceptance; HTTP transport/runtime readiness are now complete.
+
+## Open Items
+
+- Decide whether the 23 cross-worksheet Group Codes are intentionally additive or should be isolated by channel/date.
+- Add real-PostgreSQL repository/rollback coverage; PATCH/DELETE handlers and server tests are complete.
+- Record the concurrent 014/015 migration provenance, then restart and import one controlled workbook under the accepted
+  format, test review/idempotence/replacement, and run one released job with explicit write authorization.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-booking-manual-review-schema-audit.md b/.project-docs/50-evidence/topics/2026-07-31-booking-manual-review-schema-audit.md
new file mode 100644
index 0000000..eaee060
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-booking-manual-review-schema-audit.md
@@ -0,0 +1,90 @@
+# Evidence Topic: Booking manual-review schema audit
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Superseded implementation snapshot; audit-grade history gap remains active
+- Scope: Live `booking_test`, Booking parser 2.0, migrations 008/014/015 and Web review composition
+- Confidence: Fact
+- Source: repeatable-read live metadata, DDL/contract/source inspection, focused tests, process timestamps and HTTP probes
+- Last verified: 2026-07-31 11:21 +08:00
+- Stale trigger: port-8766 restart, HTTP method-adapter change, first real draft/activation, or review-policy decision
+
+## Question
+
+Can the current Booking database model and running program support parser 2.0's unrecognized-room items and a human
+confirmation loop?
+
+## Evidence
+
+- Migration 008 already supplied a coarse row gate: `parse_versions.parse_status` permits `needs_review`, while
+  `current_row_parses` and downstream views permit only accepted parses in accepted batches. The existing 867 parses are
+  all accepted; all 867 canonical room items have non-null room codes.
+- At 11:12 +08:00 the live database still lacked migrations 014/015. At 11:19 +08:00 a new read-only snapshot found
+  `booking.current_source_batch`, `booking.extraction_drafts` and `booking.extraction_draft_items`. This audit did not
+  perform those concurrent migrations.
+- The live singleton now points to accepted source batch 1. Both draft tables are empty, so no real upload/review/activate
+  transaction has yet been evidenced by their data.
+- The live draft schema supports a basic latest-state operator loop: draft `reviewing/activated/superseded`; item
+  `confirmed/pending/deleted`; source sheet/row/item, Group Code, raw hotel/room text, canonical room type, quantity,
+  automatic flag, source fragment and timestamps. A pending item must have a null canonical room type; a confirmed item
+  must have one. All new constraints are validated.
+- The schema is not an approval ledger. It has no assignee/reviewer/actor, reason/note, explicit decision timestamp,
+  before/after values, immutable item revision or append-only review event. No trigger makes closed drafts immutable or
+  enforces zero pending items at activation; these rules currently depend on application transactions.
+- The new PostgreSQL repository uses serializable transactions plus a shared advisory lock, edits only reviewing drafts,
+  rejects pending/empty activation, writes confirmed items to accepted canonical facts, switches the current source and
+  closes the draft atomically. Re-extracting the same non-activated artifact deletes its earlier draft/items, so prior
+  manual edits are not durable history.
+- The browser and business router model upload, list, edit, delete, discard and activate. However the browser sends
+  `PATCH` for confirmation and `DELETE` for item/draft removal, while `arr_web/server.py` implements only `do_GET` and
+  `do_POST`. Read-only loopback probes returned HTTP 501 for both `PATCH` and `DELETE`. The application-route unit tests
+  call the router directly and therefore do not catch this transport gap.
+- The active port-8766 process started at 2026-07-30 23:19. The new Python composition files were modified at
+  2026-07-31 11:07-11:13, and the server has no hot-reload mechanism, so the running process has not loaded the new
+  repository/routes even though the database structure is now present.
+- Twenty-seven focused parser/migration/review-contract/fake-repository/Web-route tests pass and a fresh source import
+  succeeds. No test executes draft CRUD/activation against a real PostgreSQL transaction, and no server-adapter test
+  covers `PATCH`/`DELETE`.
+
+## Follow-up Resolution
+
+Later on 2026-07-31, `arr_web/server.py` gained bounded PATCH/DELETE dispatch and real loopback socket coverage. The
+supplied workbook then passed parser replay, the live review schema passed a read-only repository probe and the complete
+source implementation was recorded in
+[Booking Excel extraction program](2026-07-31-booking-extraction-program.md). The original audit remains evidence of the
+before-state and of the still-open actor/reason/revision-history decision; its claim that PATCH/DELETE are currently
+unimplemented is no longer authoritative.
+
+## Original Point-In-Time Finding
+
+The problem is not relational normalization itself. Canonical `booking.room_items` should remain accepted facts; pending
+extraction belongs in a separate draft/workflow layer. The now-live 015 tables implement that separation and are enough
+to represent a simple single-operator correction state.
+
+At the 11:21 snapshot, the complete workflow was not runnable: the active service predated the new composition and the
+HTTP adapter could not transport manual-confirm/delete calls. The later extraction-program work repaired and loaded
+those implementation gaps. The audit-depth finding remains: if “人工确认” must be attributable and auditable, the schema
+stores only the latest edited state, not who decided what, why, and how it changed.
+
+## Original Point-In-Time Capability Classification
+
+| Capability | Live database | Current program/runtime |
+|---|---|---|
+| Keep pending extraction out of canonical reports | Yes, through separate draft tables and accepted current-source gate | Repository models it; real PostgreSQL flow untested |
+| Item pending/confirmed/deleted | Yes | UI/router/repository model it |
+| Edit room type and quantity | Schema supports latest state | At 11:21, blocked because `PATCH` returned 501; later resolved |
+| Delete item / discard draft | Schema supports status changes | At 11:21, blocked because `DELETE` returned 501; later resolved |
+| Zero-pending atomic activation | Not declaratively enforced | Repository enforces it; real DB transaction untested |
+| Reviewer/reason/history | No | No |
+| Currently loaded on port 8766 | N/A | No; process predates new Python source |
+
+## Original Open Items And Resolution
+
+- PATCH/DELETE handling, server-level tests, controlled restart and Booking-source readiness are resolved in the later
+  extraction-program evidence.
+- Add real-PostgreSQL draft CRUD, atomic activation and rollback tests before the first business workbook import.
+- If audit-grade review is required, add actor/reviewer, action/reason, event time, old/new values or immutable item
+  revisions, optimistic concurrency and database transition guards.
+- Run one controlled raw workbook through upload -> pending review -> confirmation/deletion -> activation -> company
+  report. No such business mutation was performed by this audit.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-company-action-button-compact.md b/.project-docs/50-evidence/topics/2026-07-31-company-action-button-compact.md
new file mode 100644
index 0000000..50fcda4
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-company-action-button-compact.md
@@ -0,0 +1,32 @@
+# Evidence Topic: Company-channel action button compacting
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Implemented
+- Scope: Company-channel upload action and three period generation CTAs
+- Confidence: Fact
+- Source: `arr_web/static/styles.css`, `tests/test_arr_web.py`, 28 focused tests, 348-test discovery, isolated browser geometry/screenshots and port-8766 health probe
+- Last verified: 2026-07-31 13:37:49 +08:00
+- Stale trigger: company-generation markup, responsive breakpoint or action-label change
+
+## Change
+
+- `.company-period-cta` now uses a compact `clamp(104px, 25%, 124px)` width, 42px height, centered alignment and
+  one-line 10px label treatment. Existing hover, disabled and active feedback remain in place.
+- `.company-upload-button` uses the same width/height family and is centered inside the desktop 172px action column;
+  the mobile single-column override keeps the button centered instead of stretching it full width.
+- No API, upload, extraction, report-generation or database code changed.
+
+## Verification
+
+- Static CSS assertions, JavaScript syntax and HTML parsing passed.
+- Focused `tests.test_arr_web` plus `tests.test_arr_web_company_reports`: 28/28 passed.
+- Full `.venv/bin/python -m unittest discover -s tests`: 348 passed, 10 expected optional skips, 98.283 seconds.
+- Isolated authenticated fake Web browser QA at 1280×720 and 390×844 found all three CTA center deltas at `0px`,
+  matching 104×42px upload/action geometry, one-line labels, and `document.scrollWidth` equal to the viewport width.
+  Browser console error/warning logs were empty; no upload, extraction, deletion, activation or report-generation
+  action was invoked. Screenshots showed the compact blue generation actions and subdued disabled upload action
+  without overlap.
+- Port 8766 was restarted after the change under detached Screen session `arr2-web-8766`; PID 71849 serves the
+  updated static assets and `GET /healthz` returns `ready`.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-company-card-row-and-confirm-dialog.md b/.project-docs/50-evidence/topics/2026-07-31-company-card-row-and-confirm-dialog.md
new file mode 100644
index 0000000..0c42161
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-company-card-row-and-confirm-dialog.md
@@ -0,0 +1,35 @@
+# Company-channel card row and in-page generation confirmation
+
+## Metadata
+
+- Date: 2026-07-31
+- Scope: `arr_web/static/index.html`, `arr_web/static/styles.css`, `arr_web/static/app.js`, `tests/test_arr_web.py`
+- Change type: UI composition and interaction affordance only
+- Business-data mutation: none
+
+## Behavior facts
+
+- The large `生成报表` surface now contains one desktop row ordered `上传 Excel 报表`, `第一期`, `第二期`,
+  `第三期`. The layout falls back to two columns at the tablet breakpoint and one column on narrow mobile screens.
+- The `报表月份` input remains an accessible month control but its visible label is visually hidden. It sits in the
+  setup header's upper-right corner, continues to drive C/O release/history filtering, and is still submitted as
+  `report_month` after confirmation.
+- Period card CTAs now visibly read `生成`; the period range, C/O range and release state remain visible in each card.
+- Clicking a released period opens a styled in-page `` with the selected month and period. The dialog uses a
+  restrained light card, backdrop blur, explicit `取消`/`确认生成` actions and focus on `取消` when opened.
+- Cancel, Escape and backdrop dismissal close the dialog without calling the generation API. Explicit confirmation keeps
+  the existing POST body (`report_month`, `period`) and then uses the existing job save/poll/render path. Errors remain
+  inline in the dialog for retry or cancellation. No `window.confirm` remains in the generation path.
+
+## Verification
+
+- `node --check arr_web/static/app.js`: passed.
+- HTML parser check: passed.
+- Focused suite: `28 tests`, `OK` (`tests.test_arr_web`, `tests.test_arr_web_company_reports`).
+- Full discovery: `348 tests`, `OK (skipped=10)`.
+- Isolated authenticated fake-server browser QA did not upload, extract, delete, activate or submit a report. At
+  1280x720 all four cards share one row; at 390x844 the layout is single-column and `scrollWidth` equals the viewport.
+  The generation dialog opened with `确认生成`, showed `2026年07月,01-10`, and cancel/Escape both closed it without
+  mutation. Browser console warnings/errors: none.
+- The isolated QA server on port 8876 was stopped after verification. Shared port 8766 was restarted at 14:26:56 +08;
+  its `/healthz` endpoint returns `ready`.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-company-detail-header-upload.md b/.project-docs/50-evidence/topics/2026-07-31-company-detail-header-upload.md
new file mode 100644
index 0000000..9fa32ad
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-company-detail-header-upload.md
@@ -0,0 +1,28 @@
+# Company-detail title and upload rail
+
+## Metadata
+
+- Date: 2026-07-31
+- Scope: `arr_web/static/index.html`, `arr_web/static/styles.css`, `tests/test_arr_web.py`
+- Change type: UI layout and explanatory affordance only
+- Business-data mutation: none
+
+## Behavior facts
+
+- The fixed generation scope is rendered beside `公司渠道明细` as `固定生成:LianTai、QBD、DY-AI-Easy-KB、FengRun、HanaTour` in a muted, small helper style.
+- The duplicate `固定生成公司` block under `生成报表` is removed; only the `报表月份` input remains in that generator control row.
+- `报表月份` is intentionally retained. The client uses it to calculate each C/O period and release state, query company history for the selected month, and submit `report_month` with a generation request.
+- `刷新任务` is intentionally retained. It rechecks health and reloads the current Excel source, review draft and company-generation history, then shows a toast; it does not start a report. Its `title` documents this read-only scope.
+- The month input has a non-intrusive `title` explaining that it selects the C/O report month and filters the generation records below.
+- The Excel upload rail is `min(100%, 760px)` on desktop with the 104px `提取并核对` action column; at the mobile breakpoint it becomes full-width and one column.
+
+## Verification
+
+- JavaScript syntax and HTML parser checks passed.
+- Focused suite: `28 tests`, `OK` (`tests.test_arr_web`, `tests.test_arr_web_company_reports`).
+- Full discovery: `348 tests`, `OK (skipped=10)`.
+- Isolated authenticated fake-server browser QA did not upload, extract, delete, activate or generate anything.
+- At 1280x720: source rail `760px`, dropzone `644px`, upload action `104px`, document scroll width `1280px`.
+- At 390x844: source rail/dropzone `324px`, single-column action, document scroll width `390px`; fixed-company helper wraps safely under the title.
+- Browser console warnings/errors: none.
+- Isolated QA server on port 8876 was stopped after verification; shared port 8766 was not changed during QA.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-company-period-card-copy.md b/.project-docs/50-evidence/topics/2026-07-31-company-period-card-copy.md
new file mode 100644
index 0000000..e222863
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-company-period-card-copy.md
@@ -0,0 +1,32 @@
+# Company-channel period card copy
+
+## Metadata
+
+- Date: 2026-07-31
+- Scope: `arr_web/static/index.html`, `arr_web/static/styles.css`, `arr_web/static/app.js`, `tests/test_arr_web.py`
+- Change type: display copy, card hierarchy and result-detail presentation
+- Business-data mutation: none
+
+## Behavior facts
+
+- The three generation cards no longer show the generic `第一期`, `第二期`, `第三期` labels.
+- The card headline values now read `C/O:01-10`, `C/O:11-20` and a month-aware final range: `C/O:21-30` for 30-day months or `C/O:21-31` for 31-day months.
+- These are display labels only. The internal period keys remain `01-10`, `11-20` and `21-month-end`; release-date
+  calculation, button enablement, confirmation context, POST payload and generation polling are unchanged.
+- Period-completeness chips (`周期未结束` / `周期已结束`) and the computed Bangkok completion time remain below each
+  C/O headline. The old duplicate C/O detail line was removed so each card has one clear range label.
+- A targeted execution check confirms June resolves to `C/O:21-30` and July resolves to `C/O:21-31`.
+- Result problems are deduplicated by kind/code/period for display; the internal warning and error arrays are not mutated.
+- The result table now shows `生成时间` from `job.finished_at`, falling back to `job.created_at`; `version_no` remains in
+  the API payload but is no longer exposed as a user-facing revision label.
+
+## Verification
+
+- `node --check arr_web/static/app.js`: passed.
+- HTML parser check: passed.
+- Focused suite: `28 tests`, `OK` (`tests.test_arr_web`, `tests.test_arr_web_company_reports`).
+- Full discovery: `348 tests`, `OK (skipped=10)`.
+- Targeted execution check: repeated same-code/same-period warnings collapse to one display item.
+- Isolated authenticated fake-server browser QA at 1280x720 and 390x844 confirmed all three labels, no stage-name
+  remnants, the requested C/O values and zero horizontal overflow. Page console warnings/errors: none. No upload,
+  extraction, deletion, activation or report generation was submitted.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-company-report-early-generation.md b/.project-docs/50-evidence/topics/2026-07-31-company-report-early-generation.md
new file mode 100644
index 0000000..8f25b2d
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-company-report-early-generation.md
@@ -0,0 +1,31 @@
+# Company-channel early generation and period completeness state
+
+## Metadata
+
+- Date: 2026-07-31
+- Scope: `arr_web/company_jobs.py`, `arr_web/static/app.js`, `arr_web/static/index.html`, `arr_web/static/styles.css`, `tests/test_arr_web_company_reports.py`, `tests/test_arr_web.py`
+- Change type: generation eligibility, period-state copy and confirmation guidance
+- Business-data mutation: none
+
+## Behavior facts
+
+- A current-month C/O period can be submitted before its calendar completion boundary. The backend still derives the
+  same fixed `as_of_date`: the 10th for `01-10`, the 20th for `11-20` and natural month-end for `21-month-end`.
+- The report processor continues to read the current committed Finance snapshot through that cutoff. A later source
+  commit is not retroactively inserted into an existing workbook; the user can generate the same period again.
+- Historical months remain rerunnable. Future report months are rejected by the backend and disabled in the frontend to
+  avoid creating an empty future-month workbook.
+- The right-side period state now says `周期未结束` or `周期已结束`. The state describes calendar completeness in the
+  Bangkok timezone and no longer controls whether a valid current/historical period can be generated.
+- An incomplete-period confirmation explicitly says the workbook uses current stored data and may need a later rerun.
+- `period_release_at` remains as a compatibility alias for callers/tests; the implementation-facing name is
+  `period_complete_at`.
+
+## Verification
+
+- `node --check arr_web/static/app.js`: passed.
+- Python compilation for the changed backend/tests: passed.
+- HTML parser check: passed.
+- Focused suite: `28 tests`, `OK` (`tests.test_arr_web`, `tests.test_arr_web_company_reports`).
+- Project virtual-environment full discovery: `348 tests`, `OK (skipped=10)`.
+- No real report generation, upload, database write or business-data mutation was performed.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-daily-kpi-card-height.md b/.project-docs/50-evidence/topics/2026-07-31-daily-kpi-card-height.md
new file mode 100644
index 0000000..8080818
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-daily-kpi-card-height.md
@@ -0,0 +1,32 @@
+# Daily KPI Card Size Refinement
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Implemented
+- Scope: Daily ARRIVAL DATE, processing duration and NO. OF ROOM cards
+- Confidence: Fact
+- Source: `arr_web/static/styles.css`, `tests/test_arr_web_daily_visual_ui.py`
+- Last verified: 2026-07-31
+
+## User-Facing Outcome
+
+- The three daily KPI cards retain their original grid-column widths.
+- Desktop cards now stretch to the same compact grid-row height as the upload panel, approximately one-third of the
+  prior screenshot height.
+- KPI content remains vertically centered within each equal-height card.
+- The upload card's dropzone is compressed into a horizontal icon-and-copy layout, with smaller heading, progress and
+  footer spacing so the whole row stays compact.
+- At narrow widths, the original larger upload/dropzone treatment and stacked content remain in place.
+
+## Verification
+
+- `node --check arr_web/static/app.js`: passed.
+- `python3 -m unittest tests.test_arr_web_daily_visual_ui tests.test_arr_web_task_log_ui tests.test_arr_web_job_trace tests.test_arr_web_repository_schema tests.test_arr_web.PortalApplicationTests.test_fixed_static_routes_and_required_content tests.test_arr_web.PortalApplicationTests.test_all_public_assets_exclude_removed_copy`: 20 passed.
+- No upload request, progress state, task-log behavior, API route or processing coordinator changed.
+
+## Design Boundary
+
+This is a preserve-mode refinement using the existing light neutral surfaces, blue accent, 16px card radius and native
+CSS. The selected dials remain DESIGN_VARIANCE: 3, MOTION_INTENSITY: 3 and VISUAL_DENSITY: 5; the change addresses the
+visual proportion shown in the supplied reference without adding new dependencies, motion or a new component system.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-daily-overview-layout.md b/.project-docs/50-evidence/topics/2026-07-31-daily-overview-layout.md
new file mode 100644
index 0000000..e69363c
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-daily-overview-layout.md
@@ -0,0 +1,35 @@
+# Daily Overview Layout and Copy Cleanup
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Implemented
+- Scope: Desktop daily report overview layout and visible labels
+- Confidence: Fact
+- Source: `arr_web/static/index.html`, `arr_web/static/styles.css`, `arr_web/static/app.js`, focused tests
+- Last verified: 2026-07-31
+
+## User-Facing Outcome
+
+- The duplicate top-level `Daily Report` heading is removed.
+- `THIS MONTH` is removed from the daily-history panel.
+- The daily-history heading is now `Daily Report`.
+- The ARR.XML upload station, ARRIVAL DATE card, processing-duration card and NO. OF ROOM card are direct children of
+  one responsive overview grid. They display in one row on desktop and stack on narrow screens.
+- The existing inline upload progress bar, upload request, task-log dialog and history interactions are unchanged.
+
+## Verification
+
+- `node --check arr_web/static/app.js`: passed.
+- `python3 -m unittest tests.test_arr_web_daily_visual_ui tests.test_arr_web_task_log_ui tests.test_arr_web_job_trace tests.test_arr_web_repository_schema tests.test_arr_web.PortalApplicationTests.test_fixed_static_routes_and_required_content tests.test_arr_web.PortalApplicationTests.test_all_public_assets_exclude_removed_copy`: 20 passed.
+- Frontend static-resource scan confirms the removed daily title class, `THIS MONTH` and the old daily-history wording are
+  absent from the public HTML/CSS/JavaScript surface.
+- A full `tests` discovery was attempted but not used as acceptance because six Agent integration test modules failed
+  collection in the local environment when the optional `httpx` dependency was unavailable.
+- No API route, upload contract, processing coordinator or task-trace query changed.
+
+## Design Boundary
+
+This is a preserve-mode operational UI refinement using the existing dependency-free HTML/CSS system. The chosen design
+dials were `DESIGN_VARIANCE: 3`, `MOTION_INTENSITY: 3` and `VISUAL_DENSITY: 5`: one calm four-column desktop composition,
+the existing light neutral surfaces and blue accent, and no new animation or dependency.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-daily-upload-progress-and-log-behavior.md b/.project-docs/50-evidence/topics/2026-07-31-daily-upload-progress-and-log-behavior.md
new file mode 100644
index 0000000..fa8254d
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-daily-upload-progress-and-log-behavior.md
@@ -0,0 +1,36 @@
+# Daily Upload Progress and Task-Log Behavior
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Implemented
+- Scope: Desktop daily ARR.XML upload interaction and inline progress feedback
+- Confidence: Fact
+- Source: `arr_web/static/index.html`, `arr_web/static/app.js`, `arr_web/static/styles.css`, focused tests
+- Last verified: 2026-07-31
+
+## User-Facing Outcome
+
+- Clicking `开始处理` keeps the operator on the daily page; it no longer opens the task-log dialog automatically.
+- The header `任务日志` button and a selected daily-history row still open the same trace dialog on demand.
+- The upload card exposes a compact accessible progressbar with approximate stages for upload, fixed processing,
+  independent validation and database commit.
+- The estimate advances while the synchronous upload request is in flight, caps before terminal completion, then
+  resolves to 100% on success or an inline red error state on failure.
+- The file chooser and dropzone are temporarily disabled while the request is in flight so the visible progress stays
+  attached to the submitted file.
+
+## Verification
+
+- `node --check arr_web/static/app.js`: passed.
+- `python3 -m unittest -v tests.test_arr_web_task_log_ui`: 5 passed.
+- `python3 -m unittest -v tests.test_arr_web_job_trace tests.test_arr_web_repository_schema`: 8 passed.
+- `python3 -m unittest -v tests.test_arr_web.PortalApplicationTests.test_fixed_static_routes_and_required_content tests.test_arr_web.PortalApplicationTests.test_all_public_assets_exclude_removed_copy`: 2 passed.
+- No API route, upload contract, processing coordinator or task-trace query changed.
+
+## Design Boundary
+
+This is a preserve-mode operational UI refinement. It keeps the existing dependency-free HTML/CSS, light neutral theme,
+single blue accent and rounded upload-card language. Progress values are intentionally approximate because `POST
+/api/jobs` returns after the ARR-owned processing flow reaches a terminal receipt; no fake server progress contract was
+introduced.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-live-company-job-booking-room-diagnostic.md b/.project-docs/50-evidence/topics/2026-07-31-live-company-job-booking-room-diagnostic.md
new file mode 100644
index 0000000..5485079
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-live-company-job-booking-room-diagnostic.md
@@ -0,0 +1,34 @@
+# Evidence Topic: Live 21–31 company-job Booking Room diagnostic
+
+- Date: 2026-07-31 14:52–15:00 Asia/Shanghai
+- Status: Resolved diagnostic; no code or data mutation
+- Scope: Persisted company job `8aa1fdef3b644f3a9cc0f26924445a29` and its five published XLSX artifacts
+- Confidence: Fact
+- Stale trigger: New Booking source activation, Finance-version change, report regeneration, or a processor/rule change
+
+## Facts
+
+- The job succeeded for `2026-07`, period `21-month-end` / label `21-31`, with five successful company results.
+- Artifact-tool inspection of the published workbooks found 598 populated `21-31` rows: 224 have a nonblank
+  `Booking Room` and 374 are blank. The per-company totals are LianTai 144/167, QBD 31/128,
+  DY-AI-Easy-KB 39/28, FengRun 5/33 and HanaTour 5/18 (filled/blank).
+- Current Finance facts classify as 614 with a consistent nonblank Group Code and 372 with neither Group Code field
+  populated. The latter remain their own blank-`Booking Room` output rows.
+- Of the rows with a Group Code, only `LLT260719MA` and `LT260718KC` have no current Booking source match. They account
+  for the other two blank output rows; no other nonblank Group Code yielded an empty `Booking Room`.
+- The LianTai workbook's `21-31` sheet row 26 is a representative direct match:
+  `RES_COMMENT=LT260718KB` and `Booking Room=【DBL】12`. The accepted Markdown contains twelve `LT260718KB` / `DBL`
+  source rows, so the output is the expected Group-Code aggregation.
+- `Total Booking Price` is independent of the Booking mapping. It displays the Finance fact's room-category/price
+  composition (for example `RM3` and static prices), while Booking only contributes room type and booked quantity. The
+  current Markdown source has no unit-price field from which a Booking-derived total could be calculated.
+
+## Finding
+
+There is no observed failure to write `Booking Room` in the generated job. Blank cells match the established rule for
+missing or unresolved Group Codes; the apparent all-blank impression can result from looking at the leading no-code
+rows in a workbook. In the LianTai sheet, the first matching Booking value occurs at Excel row 26.
+
+If the intended business rule is instead to derive a price from the Markdown Booking room type, that is a separate
+product change: the Markdown schema needs a price source and an explicit room-type-to-price policy. It is not supported
+by the current Group Code / room type / quantity contract.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-markdown-channel-baseline-freeze.md b/.project-docs/50-evidence/topics/2026-07-31-markdown-channel-baseline-freeze.md
new file mode 100644
index 0000000..a3fd326
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-markdown-channel-baseline-freeze.md
@@ -0,0 +1,72 @@
+# Evidence Topic: Markdown company-channel baseline freeze
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Frozen and independently verified; live publication intentionally excluded
+- Scope: Accepted Markdown Booking batch 1, July 31 Finance snapshot, processor 1.2.0 and five company XLSX outputs
+- Confidence: Fact
+- Source: exact source copy/hash, repeatable-read/read-only PostgreSQL snapshots, independent Markdown aggregation,
+  artifact-tool export/reopen/value validation, rendered-sheet review and SHA-256 inventory
+- Last verified: 2026-07-31 12:26 +08:00
+- Stale trigger: Booking current-source activation, Finance current-version change, company processor/rule change or
+  mutation of a frozen package file
+
+## Question
+
+What exact channel-processing result existed under the Markdown Booking fixture before the first real Booking Excel
+activation, and can it be preserved as an independent comparison baseline?
+
+## Evidence
+
+- The source is
+  `/Users/chillishark/ARR项目0727/outputs/res_comment_20260727/RES_COMMENT_TYPE_OF_ROOM.md`, SHA-256
+  `ec2302170c5657d3e465691997e17f7694eb5a7f76feba7e43261632209b8c23`, 39,209 bytes.
+- Independent parsing produced 867 source rows, six worksheets, 348 normalized Group Codes and total room quantity 867.
+- A read-only database guard found accepted/current source batch 1, `expected_fixture`/`md/1.0`, with the same filename,
+  byte size, hash and counts. The guard was repeated after workbook generation; the current source and current room-item
+  counts were unchanged.
+- The July 31 snapshot contains 417 supported-company Finance facts pinned to five current daily versions:
+  2026-07-20/version 5, 2026-07-21/version 4, 2026-07-22/version 6, 2026-07-23/version 7 and
+  2026-07-27/version 2. Twenty-seven requested Group Codes resolve to current Booking parse versions.
+- Processor 1.2.0/rule SHA-256
+  `12ab16062e9b4e5e3785b16cbb5724f3cf0a1d2364b8c4cb12c361a2dfd24c17` produced valid reports for all five
+  companies:
+
+| Company | Output rows | Booking Room filled | Booking Room blank | Warnings | Errors | Independent mismatches |
+|---|---:|---:|---:|---:|---:|---:|
+| LianTai | 138 | 16 | 122 | 2 | 0 | 0 |
+| QBD | 139 | 11 | 128 | 4 | 0 | 0 |
+| DY-AI-Easy-KB | 1 | 0 | 1 | 0 | 0 | 0 |
+| FengRun | 33 | 0 | 33 | 0 | 0 | 0 |
+| HanaTour | 3 | 0 | 3 | 0 | 0 | 0 |
+
+- Every output row was compared with an expectation built directly from the copied Markdown by normalized
+  `Group Code + room type`, summing quantity. All 314 rows match: 27 filled Booking Room values and 287 correct blanks.
+- The checked-in artifact-tool builder exported and reopened each XLSX, verified exact worksheet names, headers and
+  cell values, found zero formulas/formula errors, and rendered all three periods. All 15 previews were visually
+  inspected; populated and empty periods are legible with no clipping or structural defect.
+- `SHA256SUMS` verifies every retained source, mapping, payload, validation, workbook, summary and preview file. All five
+  XLSX files also pass ZIP integrity checks.
+- The package is saved at
+  `outputs/019fb225-1b2c-7211-a4c1-2b12b482f6c1/markdown-channel-baseline-20260731-md-ec230217/`.
+- The operation invoked neither the company publisher nor any database mutation. One reviewing Booking Excel draft
+  existed before and after the freeze, remained outside accepted facts and was not edited or activated.
+
+## Finding
+
+The Markdown-driven channel-processing result is now a durable, independently checkable baseline rather than a
+temporary preview. It proves current processor correctness for the exercised data and preserves the exact pre-Excel
+comparison point. It does not claim Web job persistence, report-version registration or post-activation integration.
+
+## Impact
+
+- The current Excel draft can be reviewed and deliberately activated later without losing the prior expected result.
+- Post-activation comparison should use the frozen source/hash, manifest, row-level comparison and workbook hashes rather
+  than re-querying a changed current source.
+- A released five-company Web job remains necessary to accept task creation, publication metadata and downloads.
+
+## Open Items
+
+- Finish or delete all pending items in the current Excel draft, activate the complete replacement deliberately, then
+  generate one released five-company job and compare its five overlapping Group Codes against the Excel review result.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-markdown-current-source-release-recheck.md b/.project-docs/50-evidence/topics/2026-07-31-markdown-current-source-release-recheck.md
new file mode 100644
index 0000000..315a23d
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-markdown-current-source-release-recheck.md
@@ -0,0 +1,41 @@
+# Evidence Topic: Markdown current source and July release recheck
+
+- Date: 2026-07-31 14:14–14:17 Asia/Shanghai
+- Scope: Current Booking pointer, open-review state, July company-report period distribution and read-only processor preview
+- Mutation: None
+
+## Evidence
+
+- A direct read-only repository check found `booking.current_source_batch` still selecting batch 1,
+  `expected_fixture`, with 867 source rows, six worksheets, 348 distinct Group Codes and total room quantity 867.
+- The current-review query returned no reviewing Booking Excel draft. The previously observed draft is therefore no
+  longer a live company-task blocker, and no Booking source switch occurred.
+- The July 31 company-report snapshot now contains 986 supported-company Finance facts. Their C/O dates range from
+  2026-07-21 through 2026-07-30: zero facts are in `01-10`, zero are in `11-20` and all 986 are in `21-month-end`.
+- A read-only in-memory processor 1.2.0 preview remains valid for all five companies:
+
+| Company | Output rows | Booking Room filled | Booking Room blank | Errors |
+|---|---:|---:|---:|---:|
+| LianTai | 311 | 144 | 167 | 0 |
+| QBD | 159 | 31 | 128 | 0 |
+| DY-AI-Easy-KB | 67 | 39 | 28 | 0 |
+| FengRun | 38 | 5 | 33 | 0 |
+| HanaTour | 23 | 5 | 18 | 0 |
+| Total | 598 | 224 | 374 | 0 |
+
+- The Web release rule opens July `21-month-end` at 2026-08-01 00:00 Asia/Bangkok, which is
+  2026-08-01 01:00 Asia/Shanghai. At the time of the check the period was not released.
+
+## Finding
+
+Markdown remains the current Booking lookup source and the deterministic processor can already be tested directly.
+Because all currently relevant Finance facts fall in `21-month-end`, the earlier released July periods can only produce
+empty results. A formal Web-created populated job must wait for the release boundary.
+
+The frozen 314-row Markdown package remains a valid, immutable result for its pinned five-version Finance snapshot. It
+must not be mistaken for the later live 986-fact projection; the current read-only preview has 598 output rows.
+
+## Open Item
+
+After 2026-08-01 01:00 Asia/Shanghai, create one July `21-month-end` five-company Web job while Markdown batch 1 remains
+current, then verify persistence, five downloads and output counts against the job's own pinned snapshot.
diff --git a/.project-docs/50-evidence/topics/2026-07-31-standard-monthly-page-cleanup.md b/.project-docs/50-evidence/topics/2026-07-31-standard-monthly-page-cleanup.md
new file mode 100644
index 0000000..fbfff71
--- /dev/null
+++ b/.project-docs/50-evidence/topics/2026-07-31-standard-monthly-page-cleanup.md
@@ -0,0 +1,35 @@
+# Evidence Topic: Standard monthly page cleanup
+
+## Metadata
+
+- Date: 2026-07-31
+- Status: Implemented; focused checks pass
+- Scope: desktop monthly tab DOM/CSS/JS and monthly-run Web response contract
+- Confidence: Fact
+- Source: static inspection, focused Web/schema tests and JavaScript/Python syntax checks
+- Last verified: 2026-07-31
+- Stale trigger: monthly table columns, monthly-run response shape, publication watermark rule or desktop navigation changes
+
+## Request
+
+Does the standard monthly page remove the redundant version-history surface while keeping the correct ARRIVAL watermark?
+
+## Changes
+
+- The monthly tab is labeled `月报`; the page heading, `VERSION HISTORY`, `标准月报版本记录` title and C/O-period footer sentence are removed.
+- The top of the remaining list is identified by the concise `月报处理` heading alongside the live-update state.
+- The visible monthly table now has six columns: `更新至`, `状态`, `数据行`, `公司数`, `生成时间` and `月报`. The separate company-report `版本` column is unchanged.
+- The repository exposes `max_arrival_date` as an explicit alias of `reporting.monthly_runs.as_of_date`; `as_of_date` remains in the response for compatibility. Publication rules already define that stored date as the greatest included `ARRIVAL`.
+- The frontend renders `max_arrival_date` with an `as_of_date` fallback for older fixtures, preserving 50-row pagination, ordering and four-second visible-tab polling.
+- The remaining panel uses a compact live-status toolbar, a wider six-column table and responsive table overflow rather than leaving the removed title block as empty space.
+
+## Verification
+
+- `node --check arr_web/static/app.js` passed.
+- Python compilation passed for the changed repository/app/tests modules.
+- `tests.test_arr_web`, `tests.test_arr_web_repository_schema`, `tests.test_arr_web_task_log_ui` and `tests.test_arr_web_daily_visual_ui` passed: 32 tests.
+- The live authenticated browser tab was not available for this check; a fresh unauthenticated local tab correctly redirected to `/login`, so no credentials were entered and no business action was attempted.
+
+## Finding
+
+The UI now displays the monthly watermark from the same persisted maximum-ARRIVAL source used by monthly publication, without exposing version-history language or the monthly version number as a visible column. No report, Finance or database business data was mutated.
diff --git a/.project-docs/60-reflection/cases/2026-07-29-probe-stream-semantics-before-trace-capture.md b/.project-docs/60-reflection/cases/2026-07-29-probe-stream-semantics-before-trace-capture.md
new file mode 100644
index 0000000..bdb933c
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-29-probe-stream-semantics-before-trace-capture.md
@@ -0,0 +1,35 @@
+# Probe Stream Semantics Before Designing Trace Capture
+
+## Trigger
+
+The user pointed to SuperAgent's
+`/messages/stream?include_trace=true` capability after the first task-log
+implementation reconstructed only ARR database facts. The URL alone did not
+establish whether it could replay an existing run or only create a new one.
+
+## Evidence
+
+- `OPTIONS`, `HEAD` and `GET` returned `405 Allow: POST`.
+- A no-business-data probe showed the first SSE event was `run.started`, followed
+  by task/step updates and a terminal run event/end marker.
+- Repeating the exact same message and idempotency key created a different
+  `run_id`; the endpoint did not replay or deduplicate the prior run.
+
+## Lesson
+
+Do not infer read/replay semantics from an observability query parameter. Before
+wiring trace capture, verify the HTTP method, event order, connection lifetime
+and duplicate-submission behavior with an isolated harmless request. If trace is
+emitted only while creating the run, return the handle from the first start event
+and keep consuming that same connection; never POST again merely to recover
+diagnostics.
+
+## Reusable Check
+
+1. Probe non-mutating methods first and inspect `Allow`/Content-Type.
+2. Use a separate no-PII session for the smallest authorized POST probe.
+3. Record only schema/event names until the privacy boundary is known.
+4. Test identical idempotency keys explicitly; do not assume endpoint parity.
+5. Treat an uncertain post-send failure as ambiguous when the server cannot
+   prove deduplication.
+
diff --git a/.project-docs/60-reflection/cases/2026-07-29-verify-runtime-target-and-entrypoint-before-e2e.md b/.project-docs/60-reflection/cases/2026-07-29-verify-runtime-target-and-entrypoint-before-e2e.md
new file mode 100644
index 0000000..c64361d
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-29-verify-runtime-target-and-entrypoint-before-e2e.md
@@ -0,0 +1,44 @@
+# Reflection: Verify Runtime Target and Entrypoint Before E2E
+
+## Trigger
+
+Debugging initially followed the obsolete local MCP/ngrok path even after the user had moved services to a server environment. A subsequent SuperAgent export was also a manual chat-upload processor test rather than the accepted ARR Web upload flow.
+
+## Expected Behavior
+
+Before changing credentials or diagnosing transport, identify the exact MCP URL currently saved in SuperAgent and verify that the probe targets that runtime. Before calling a run “end to end,” require an ARR-created `job_id`, attempt identity, OSS provenance, submission grant, MCP receipt, and database evidence.
+
+## Actual Behavior
+
+The local temporary gateway was authenticated successfully but was irrelevant to the server deployment. The manual Agent run processed XML and presented files without `fetch_oss_file` or `arr_submit_processing_result`, so it could never prove the database path.
+
+## Root Cause
+
+- Stale runtime assumption
+- Test-mode confusion
+- Acceptance gate applied too late
+
+## Evidence
+
+- User correction that the active MCP is server-side rather than the temporary local tunnel
+- `.project-docs/50-evidence/topics/2026-07-29-superagent-manual-xml-run-bypassed-mcp.md`
+- `deploy/README.md`
+
+## Lesson
+
+Use a two-part preflight for every external E2E:
+
+1. Record the exact configured endpoint and confirm the probe reaches that endpoint.
+2. Classify the test entry as manual processor validation or ARR program-triggered ingestion before interpreting any Agent status.
+
+Never synchronize credentials or restart a runtime until both checks identify it as the active target.
+
+## Action
+
+- Preserve the exported-run evidence and current blocker.
+- Require server Web upload plus authoritative MCP/database signals for the next acceptance attempt.
+
+## Promotion
+
+Promoted to current-state risks and the evidence index. No architecture decision changes.
+
diff --git a/.project-docs/60-reflection/cases/2026-07-30-backend-automation-must-reconcile-the-open-ui.md b/.project-docs/60-reflection/cases/2026-07-30-backend-automation-must-reconcile-the-open-ui.md
new file mode 100644
index 0000000..d5d663a
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-30-backend-automation-must-reconcile-the-open-ui.md
@@ -0,0 +1,38 @@
+# Reflection: Backend Automation Must Reconcile The Open UI
+
+## Trigger
+
+The worker published monthly reports automatically, but the open page still required a person to click “刷新” before
+the new version appeared.
+
+## Expected Behavior
+
+An automated business workflow includes both durable backend completion and automatic reconciliation of the visible
+user interface. Once publication is committed, an open list should discover it without another user action.
+
+## Actual Behavior
+
+The page loaded monthly versions only at boot or from a refresh-button handler. Backend automation was complete, but
+the UI held a stale snapshot indefinitely.
+
+## Root Cause
+
+- Backend completion and frontend observability were treated as separate acceptance concerns.
+- The asynchronous worker cannot return its result through the original upload HTTP response.
+- No polling, push channel or visibility-resume contract existed for the monthly list.
+
+## Lesson
+
+For asynchronous publication, acceptance must cover how an already-open client observes the committed result. Prefer a
+bounded, visibility-aware synchronization mechanism that avoids overlapping requests and preserves the last good state.
+
+## Action
+
+- Poll only while the monthly tab/document is visible and reload immediately when it becomes active.
+- Keep the last good rows on transient background errors and retry automatically.
+- Remove the manual refresh control from the primary workflow.
+- Verify both rendered state and actual repeated API requests.
+
+## Promotion
+
+Promoted to ADR-001, success criteria, business rules, architecture data flow and the Web static contract tests.
diff --git a/.project-docs/60-reflection/cases/2026-07-30-coordinate-hot-static-and-long-lived-backend.md b/.project-docs/60-reflection/cases/2026-07-30-coordinate-hot-static-and-long-lived-backend.md
new file mode 100644
index 0000000..6bd52fb
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-30-coordinate-hot-static-and-long-lived-backend.md
@@ -0,0 +1,54 @@
+# Reflection: Coordinate Hot Static Assets With The Long-Lived Backend
+
+## Trigger
+
+The active port-8766 Python process predated the application-login routes, but it reads HTML/CSS/JavaScript from the
+workspace on every request. Editing logout/login frontend assets therefore created a temporary new-frontend / old-backend
+combination before any intentional process restart.
+
+## Expected Behavior
+
+A route-contract change should activate as one compatible release: the frontend should not advertise a control whose
+backend endpoint is absent, and a fail-closed backend should not be restarted until required credentials exist.
+
+## Actual Behavior
+
+The long-lived process continued issuing anonymous CSRF sessions without `username` and returned 404 for `/login`,
+while newer static files were immediately visible from disk. Restarting it without credentials would have taken the
+whole portal offline.
+
+## Root Cause
+
+- Static assets and Python route code had different activation lifecycles.
+- The current workstation process is manually coordinated rather than deployed as one immutable image/restart.
+- Login correctly fails startup closed, so activation depends on an operator-owned secret choice.
+
+## Evidence
+
+- Files: `arr_web/app.py`, `arr_web/static/app.js`, `arr_web/static/h5.js`
+- Runtime: PID 37865 on port 8766
+- Evidence: `50-evidence/topics/2026-07-30-web-login-runtime-mismatch.md`
+- Verification: old `/api/session` shape lacks `username`; old `/login` returns 404; isolated new runtime passes login/logout.
+
+## Lesson
+
+Treat hot-read static files plus a long-lived backend as a rolling mixed-version deployment. Before editing, inspect the
+active route/session contract. Make new controls feature-detect the new response shape and stay hidden on the old one;
+then restart once only after every fail-closed runtime input is ready.
+
+## Action
+
+- Logout controls start hidden and are revealed only when `/api/session` returns an authenticated `username`.
+- Keep an old process running until every fail-closed secret exists; if it has already exited, restore only after the
+  complete previous non-secret runtime composition is also recovered.
+- Store the Web password and OSS credentials in Keychain. The user-local launcher contains only allowlisted route
+  parsing, paths and Keychain labels; it never commits or prints values.
+- A launchd-owned process cannot read this Desktop-hosted runtime under the current macOS privacy boundary. The active
+  local workaround is a desktop-authorized detached Screen session; production should use an approved supervisor and
+  non-Desktop deployment path.
+- Record the exact coordinated restart and acceptance sequence in current state, evidence, stale items and commitments.
+
+## Promotion
+
+Promoted to the frontend compatibility contract, current-state restart gate, login-runtime evidence and this reusable
+reflection. No broader ADR is required.
diff --git a/.project-docs/60-reflection/cases/2026-07-30-current-projection-must-exclude-fixtures.md b/.project-docs/60-reflection/cases/2026-07-30-current-projection-must-exclude-fixtures.md
new file mode 100644
index 0000000..93c1f9a
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-30-current-projection-must-exclude-fixtures.md
@@ -0,0 +1,46 @@
+# Reflection: Current projections must exclude fixtures
+
+## Trigger
+
+Real daily uploads and automatic monthly publication were enabled while the database acceptance fixture remained in
+`finance.current_daily_versions`, causing correct aggregation of non-business test data.
+
+## Expected Behavior
+
+The transition from schema acceptance to business-facing use should retire all fixture pins and verify that every
+current Finance source has operational provenance.
+
+## Actual Behavior
+
+The fixture's immutable version and current pointer were both retained. Later features validated formulas, lineage,
+downloads and max-ARRIVAL consistency against a structurally valid but contaminated snapshot.
+
+## Root Cause
+
+- Weak gate
+- Unclear ownership
+
+The handoff documented synthetic-data cleanup as a production gate, but no executable readiness check owned it.
+
+## Evidence
+
+- Current version 2: run `mvp-v1-fixture-20260727`, `synthetic.xml`, provider `local_fixture`.
+- [Channel BI contamination evidence](../../50-evidence/topics/2026-07-30-channel-bi-post-update-data-contamination.md)
+- `DATABASE_ARCHITECTURE_REASSESSMENT_V2.md` already required synthetic cleanup before production.
+
+## Lesson
+
+Structural reconciliation proves that downstream outputs match current facts; it does not prove those facts are
+operational. Before business-facing acceptance, assert provenance as well as counts, hashes and formulas.
+
+## Action
+
+- Add check/eval
+- Update gate
+
+Future deployment/readiness work should fail when a current Finance version uses `local_fixture` or another explicitly
+non-operational source. Cleanup must preserve immutable history while removing the fixture from the current projection.
+
+## Promotion
+
+Promote this to a deployment/data-readiness gate and automated acceptance check when remediation is implemented.
diff --git a/.project-docs/60-reflection/cases/2026-07-30-derive-report-watermarks-from-business-facts.md b/.project-docs/60-reflection/cases/2026-07-30-derive-report-watermarks-from-business-facts.md
new file mode 100644
index 0000000..c44b540
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-30-derive-report-watermarks-from-business-facts.md
@@ -0,0 +1,45 @@
+# Reflection: Derive Report Watermarks From Business Facts
+
+## Trigger
+
+The user corrected the proposed monthly cutoff source: the Opera XML filename has no date, and the monthly “更新至”
+must come from the table's `ARRIVAL` field.
+
+## Expected Behavior
+
+Identify the authoritative business field before deriving a report period or label. Use the committed facts actually
+included in the report, not transport metadata, upload names, request time or a nearby technical date.
+
+## Actual Behavior
+
+Earlier planning left the cutoff derivation policy open and existing code accepted a request cutoff, which allowed a
+7.30 filename even though the maximum included `ARRIVAL` was 7.27.
+
+## Root Cause
+
+- Source identity was conflated with business time.
+- An internal request field was trusted without reconciling it to the generated dataset.
+- The output label was validated structurally but not against the report's semantic maximum date.
+
+## Evidence
+
+- User correction on 2026-07-30.
+- The pre-fix manual workbook was named “更新至7.30” while included rows ended at `ARRIVAL=2026-07-27`.
+- The repaired live report stores `as_of_date=2026-07-27` and core validation enforces equality with max included
+  `ARRIVAL`.
+
+## Lesson
+
+For derived period labels, trace the value to the authoritative business column and assert it again against the final
+dataset. Filenames and wall-clock timestamps are provenance/audit data, not business watermarks unless the domain
+explicitly says otherwise.
+
+## Action
+
+- Encode max-included-`ARRIVAL` in ADR-001 and durable business rules.
+- Derive worker scope through `daily_version_id` lookups rather than payload/filename dates.
+- Reject any non-empty monthly report whose stored `as_of_date` differs from its maximum included `ARRIVAL`.
+
+## Promotion
+
+Promoted to ADR-001, the data flow, business rules, migration column comment and monthly core/worker tests.
diff --git a/.project-docs/60-reflection/cases/2026-07-30-distinguish-snapshot-from-batch-completeness.md b/.project-docs/60-reflection/cases/2026-07-30-distinguish-snapshot-from-batch-completeness.md
new file mode 100644
index 0000000..126ffa1
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-30-distinguish-snapshot-from-batch-completeness.md
@@ -0,0 +1,45 @@
+# Reflection: Distinguish a database snapshot from batch completeness
+
+## Trigger
+
+The first Channel BI diagnosis treated the three real versions visible at 15:44 as the complete intended input set and
+declared 307 the clean expected total. The user's 07-23 upload committed later at 16:12 and raised the real total to 416.
+
+## Expected Behavior
+
+A live-update diagnosis should timestamp its snapshot, enumerate expected source files/dates, check recent or in-flight
+runs, and distinguish “currently persisted” from “business batch complete” before declaring an expected total.
+
+## Actual Behavior
+
+The diagnosis correctly found the one-row fixture but incorrectly promoted the then-current 307-row database subtotal
+to the final business expectation. The user's correction was required to reopen the missing-date audit.
+
+## Root Cause
+
+- Weak gate
+- Other: temporal incompleteness was mistaken for data correctness
+
+## Evidence
+
+- 15:44 snapshot: three real current versions totaling 307 plus one fixture.
+- 16:12:27: daily version 7 committed 109 retained rooms for 07-23.
+- Final real total: 416; live source total with fixture: 417.
+- [Channel BI contamination evidence](../../50-evidence/topics/2026-07-30-channel-bi-post-update-data-contamination.md)
+
+## Lesson
+
+PostgreSQL is authoritative for what is committed at a given instant, but it does not by itself prove that every
+expected source has arrived. Live batch diagnoses need both state reconciliation and completeness reconciliation.
+
+## Action
+
+- Update gate
+- Add check/eval
+
+Before declaring a clean expected total after uploads, list expected dates, query recent runs, state the snapshot time,
+and wait for or explicitly exclude any missing/in-flight input.
+
+## Promotion
+
+Promote this to the data-diagnosis checklist and future automated batch-completeness checks.
diff --git a/.project-docs/60-reflection/cases/2026-07-30-keep-deterministic-results-machine-to-machine.md b/.project-docs/60-reflection/cases/2026-07-30-keep-deterministic-results-machine-to-machine.md
new file mode 100644
index 0000000..dff8621
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-30-keep-deterministic-results-machine-to-machine.md
@@ -0,0 +1,45 @@
+# Keep Deterministic Results Machine-to-Machine
+
+## Trigger
+
+The fixed processor produced a complete valid 135-record result, but the model-mediated MCP call submitted only 20
+records. Prompt strengthening improved tool selection and fetch behavior but could not guarantee lossless serialization
+of a large structured payload.
+
+## Expected Behavior
+
+Once deterministic code has created a schema-valid result, the receiving system should validate and ingest that exact
+machine artifact without asking a model to reconstruct the payload as tool arguments.
+
+## Actual Behavior
+
+The result crossed an unnecessary model serialization boundary. That boundary introduced truncation, rereads and
+retry ambiguity even though both the source fetch and deterministic business processing had already succeeded.
+
+## Root Cause
+
+- Unclear ownership of structured-result transport
+- A probabilistic orchestration boundary was placed between deterministic producer and deterministic consumer
+
+## Evidence
+
+- `.project-docs/50-evidence/topics/2026-07-30-superagent-fetch-oss-prompt-experiment.md`
+- `.project-docs/50-evidence/topics/2026-07-30-arr2-programmatic-pipeline.md`
+- `arr_web/programmatic.py`
+- `tests/test_arr_programmatic.py`
+
+## Lesson
+
+Use models to interpret ambiguous inputs or choose work when that is genuinely required. Do not use a model as the
+transport encoder for a complete, machine-generated business result. Preserve the original artifact, validate it at the
+system boundary, and make the database commit authoritative.
+
+## Action
+
+- Updated the architecture through ADR-004.
+- Added programmatic success/failure vertical slices and active-runtime deployment guards.
+
+## Promotion
+
+Promoted to ADR-004, the ARR2.0 architecture documents, deployment contracts and regression tests. No new skill is
+needed unless this pattern recurs in another project.
diff --git a/.project-docs/60-reflection/cases/2026-07-31-formatting-is-not-business-state.md b/.project-docs/60-reflection/cases/2026-07-31-formatting-is-not-business-state.md
new file mode 100644
index 0000000..8aa6657
--- /dev/null
+++ b/.project-docs/60-reflection/cases/2026-07-31-formatting-is-not-business-state.md
@@ -0,0 +1,42 @@
+# Reflection: Formatting is not business state
+
+## Trigger
+
+An early Booking parser interpretation treated a yellow source row as cancellation. The supplied real workbook showed
+that a fully yellow row can still be the authoritative active Tour Code row, and the user confirmed that only explicit
+source cancellation text should cancel it.
+
+## Expected Behavior
+
+The parser should derive replacement/cancellation from explicit business content and documented row order, not from
+presentation-only cell formatting.
+
+## Actual Behavior
+
+A visual formatting heuristic could have removed a valid final Tour Code allocation.
+
+## Root Cause
+
+- Weak gate: a spreadsheet style was promoted to business meaning without an explicit contract or counterexample test.
+
+## Evidence
+
+- Files: `booking_ingestion/excel.py`, `tests/test_booking_excel_ingestion.py`
+- Command output: the supplied workbook retains its yellow final row; the regression test
+  `test_yellow_formatting_alone_does_not_cancel_a_tour` passes.
+- Docs involved: `.project-docs/40-domain/business-rules.md`
+
+## Lesson
+
+Spreadsheet color, font, border and other presentation metadata must remain non-authoritative unless the user explicitly
+defines that formatting as a business field. When row state matters, prefer text/column contracts and add a real-style
+counterexample before accepting the rule.
+
+## Action
+
+- Add check/eval: retain a yellow-row regression fixture and keep cancellation text-based.
+- Update docs: promote the rule to durable Booking business rules.
+
+## Promotion
+
+Business rule plus parser regression test.
diff --git a/.project-docs/60-reflection/reflection-index.md b/.project-docs/60-reflection/reflection-index.md
index 7cf1571..15b146c 100644
--- a/.project-docs/60-reflection/reflection-index.md
+++ b/.project-docs/60-reflection/reflection-index.md
@@ -4,6 +4,15 @@ Use this index for second-order workflow lessons.
 
 | Date | Reflection | Trigger | Action | Detail |
 |---|---|---|---|---|
+| 2026-07-31 | Formatting is not business state | A yellow final Booking row was initially at risk of being interpreted as cancellation | Make cancellation text-based, preserve formatting as non-authoritative and keep a real-style regression | [Case](cases/2026-07-31-formatting-is-not-business-state.md) |
+| 2026-07-30 | Coordinate hot static assets with the long-lived backend | Login static files activated before backend restart; later launchd ownership also hit macOS Desktop privacy | Feature-detect mixed versions, gate restart on all secrets, use a secret-free launcher and a desktop-authorized supervisor until deployment leaves Desktop | [Case](cases/2026-07-30-coordinate-hot-static-and-long-lived-backend.md) |
+| 2026-07-30 | Distinguish a database snapshot from batch completeness | A 15:44 three-file snapshot was incorrectly declared the clean total before the 16:12 07-23 commit | Timestamp snapshots, enumerate expected dates and reconcile recent/in-flight runs before declaring completeness | [Case](cases/2026-07-30-distinguish-snapshot-from-batch-completeness.md) |
+| 2026-07-30 | Current projections must exclude fixtures | A structurally valid `local_fixture` remained current during real uploads and contaminated BI/monthly outputs | Add a provenance-aware deployment/readiness gate and retire fixture pins without deleting audit history | [Case](cases/2026-07-30-current-projection-must-exclude-fixtures.md) |
+| 2026-07-30 | Backend automation must reconcile the open UI | Worker publication was automatic but the page still required “刷新” | Add visible-tab synchronization, last-good-state retention and UI/API cadence verification | [Case](cases/2026-07-30-backend-automation-must-reconcile-the-open-ui.md) |
+| 2026-07-30 | Derive report watermarks from business facts | User clarified that XML filenames have no date and “更新至” belongs to table `ARRIVAL` | Derive and revalidate the watermark against max included `ARRIVAL`; ignore filename/wall clock | [Case](cases/2026-07-30-derive-report-watermarks-from-business-facts.md) |
+| 2026-07-30 | Keep deterministic results machine-to-machine | A complete 135-record processor result became a 20-record model/MCP submission | Remove the model serialization boundary; validate and ingest the original machine artifact directly | [Case](cases/2026-07-30-keep-deterministic-results-machine-to-machine.md) |
+| 2026-07-29 | Probe stream semantics before trace capture | `include_trace` looked like a possible read/replay capability, but the endpoint contract was unknown | Verify method/event order/duplicate-key behavior with a harmless probe; keep one SSE connection and terminalize ambiguous sends | [Case](cases/2026-07-29-probe-stream-semantics-before-trace-capture.md) |
+| 2026-07-29 | Verify runtime target and entrypoint before E2E | Debugging followed an obsolete local tunnel and a manual chat upload was mistaken for the accepted ingestion path | Record the exact active endpoint and classify the test entry before changing credentials or accepting results | [Case](cases/2026-07-29-verify-runtime-target-and-entrypoint-before-e2e.md) |
 | 2026-07-29 | Verify every production container entrypoint | XML processing was enabled in Compose but omitted from Dockerfile CMD, so direct image launches stayed closed | Add a regression test for Dockerfile CMD and document platform command overrides | [Case](cases/2026-07-29-verify-all-container-entrypoints.md) |
 | 2026-07-29 | Remote Agent success is not a database commit | Repeated live runs ended remotely without an MCP submission | Add a multi-signal acceptance gate and preserve failed-run cleanup evidence | [Case](cases/2026-07-29-remote-success-is-not-a-database-commit.md) |
 | 2026-07-29 | Internal parameters are not user inputs | Human correction of the monthly-report workflow | Update docs and promote the boundary to ADR-001 | [Case](cases/2026-07-29-internal-parameters-are-not-user-inputs.md) |
diff --git a/.project-docs/80-commitments/commitments.md b/.project-docs/80-commitments/commitments.md
index 8a50de4..94de4e0 100644
--- a/.project-docs/80-commitments/commitments.md
+++ b/.project-docs/80-commitments/commitments.md
@@ -1,11 +1,15 @@
 # Commitments
 
-Track future-facing memory: promised follow-ups, unfinished loops, timed checks, and restart points.
+No timed or externally delegated commitment is currently active.
 
-| Date | Commitment | Trigger / Due | Owner | Status | Next Action |
-|---|---|---|---|---|---|
-| 2026-07-29 | Deploy the stable public Web/MCP profile, refresh SuperAgent discovery and rerun the no-PII XML vertical slice | Before enabling real business XML upload | User: deploy/DNS/Secrets/platform save; Codex: verify the next E2E when asked | Pending user deployment | Follow `deploy/README.md`; require Web `processing_ready=true`, MCP unauthenticated 401 and authenticated one-tool discovery; rebind/save/publish SuperAgent against the stable domain, then require an MCP commit receipt plus matching Finance facts. |
+## Open Product Work (not a promise in this task)
 
-## Use
-
-Record only commitments that should affect future sessions. Routine next steps can stay in `30-worklog/current-state.md`.
+- Login credentials, port-8766 activation, authenticated history and logout are complete. One controlled no-PII upload
+  filename check and one released 5/5 company-report job under processor 1.2.0 remain open product acceptance work.
+- The Booking extraction/review program is source-complete, migrated and transaction-tested against live PostgreSQL with
+  an outer rollback. One operator-authorized real upload/edit/delete/activate flow remains open product acceptance work;
+  this record is not authorization to change the current Booking source.
+- Rotate the Web password to a value distinct from the public username, update only the existing Keychain item and
+  perform one controlled restart/login/logout check.
+- Package Node/artifact-tool and a shared output volume into the eventual production worker runtime.
+- Run one controlled no-PII XML vertical slice on the eventual ARR2.0 server deployment.
diff --git a/.project-docs/90-maintenance/stale-items.md b/.project-docs/90-maintenance/stale-items.md
index f8a7dc5..5464dbf 100644
--- a/.project-docs/90-maintenance/stale-items.md
+++ b/.project-docs/90-maintenance/stale-items.md
@@ -1,14 +1,27 @@
 # Stale Items
 
-## Possibly Stale Or Conflicting
+## Active Conflicts / Gaps
 
-| Date | Document | Issue | Needed Confirmation |
+| Date | Document / Module | Issue | Next Action |
 |---|---|---|---|
-| 2026-07-29 | `arr_web/static/index.html`, `arr_web/static/app.js`, `arr_web/app.py` | Current monthly UI/API requires a manual generation action and sends `month` plus `as_of_date`, conflicting with ADR-001. | No requirement confirmation needed; implementation change requires a separate authorized task. |
-| 2026-07-29 | `monthly_reports/xlsx/build_workbook.mjs` and related tests | Current audit found static values and a zero-formula policy; accepted output requires `TOTAL PRICE` formulas. | No requirement confirmation needed; implementation and test updates remain. |
-| 2026-07-29 | Historical `.planning/` notes and handoff documents that say users select report periods or that monthly XLSX values are static | Those statements are superseded by ADR-001 and must not be used as current requirements. | Consult ADR-001; preserve old text only as labeled history/evidence. |
-| 2026-07-29 | `arr_processing.runner.ProcessingRunner.poll()` and `arr_processing.postgres.PostgresProcessingState` | The runner emits `delivery_missing` after remote success without a committed result, but the PostgreSQL adapter does not map that status to a valid terminal database state. | No requirement confirmation needed; implement and test automatic `PROCESSING_RESULT_MISSING` terminalization in a separately authorized code task. |
+| 2026-07-31 | Booking raw Excel parser/review business acceptance | Parser 2.0, migrations 014/015, atomic draft repository, routes, editor, HTTP transport and current runtime are ready. Supplied-workbook replay, 55 expanded tests, live 5/5 processing and a real-PostgreSQL create/edit/activate/rollback slice pass. No authorized real workbook has replaced the current source | Run one operator-authorized complete-workbook upload/review/activation, then one released five-company job and verify all downloads |
+| 2026-07-31 | Booking review audit depth | Migration 015 stores current confirmed/pending/deleted state but no reviewer, reason, explicit review event, old/new values or immutable revision history. Repository logic serializes and gates activation, but declarative DB guards are limited and same-artifact re-extraction deletes earlier unactivated edits | Confirm whether single-operator latest-state editing is sufficient. If review must be auditable, add actor/reason/event or revision tables, optimistic concurrency and DB transition guards before migration acceptance |
+| 2026-07-31 | Booking Excel current-source runtime acceptance | Current port 8766 loads the review composition with all readiness flags true; migrations 014/015 are recorded and `booking.current_source_batch` still selects Markdown batch 1. A 14:14 +08 read-only recheck found no reviewing draft; the prior draft is no longer an active task-creation blocker and did not switch the current source. The Markdown five-workbook baseline has been frozen with exact hashes | Run one Markdown-backed five-company Web job after the July period completes if a final-period snapshot is desired, then verify downloads. Keep any later complete-workbook activation operator-authorized, then compare its five overlapping Group Codes with the frozen baseline |
+| 2026-07-31 | Booking Group Code uniqueness invariant | Current views globally merge the same Group Code across worksheets. The fixture has 23 codes shared by `DY-AI-Easy-KB` and `LIANTAI-FIT`; none is used by current Finance, so current output is unaffected but real-source semantics are not confirmed | Confirm whether those rows are intentionally additive. If Group Code can vary by channel or stay segment, add explicit business dimensions to the Excel contract, schema and report join before real imports |
+| 2026-07-30 | Port-8766 business acceptance / supervision | Authenticated Web on `*:8766` now loads Booking review and all readiness flags are true; the detached Screen session is not reboot-persistent. July month-end completion is 2026-08-01 00:00 Bangkok | Run one controlled real review flow plus one company-report job/all five downloads, preferably after the period completes for a final snapshot; separately choose a managed reboot-persistent supervisor that can access the Desktop-hosted project |
+| 2026-07-30 | Web credential strength | The operator-selected password satisfies the enforced minimum length but is not distinct from the public username | Rotate the password to a distinct high-entropy value in the existing Keychain item, then perform one controlled Web restart and login/logout check |
+| 2026-07-30 | `booking_test` current Finance projection | The earlier 417-row snapshot included accepted run `mvp-v1-fixture-20260727` from `synthetic.xml`/`local_fixture`. A 2026-07-31 14:17 read-only company-report recheck found 986 current supported-company facts, so the old 416/417 remediation target is no longer a complete description of current Finance state | Re-audit current daily-version pins and source provenance before any fixture retirement; require explicit authorization for version changes, then verify the clean target and watermark |
+| 2026-07-30 | Channel BI refresh lifecycle | Analytics loads only at boot/month change/first tab entry; successful XML upload and later BI-tab entries do not reload it. The original 308-versus-417 observation is historical, and the current company projection has since advanced to 986 supported-company facts | Refresh analytics after successful upload and on visible BI-tab entry, with non-overlap/error handling and focused frontend coverage |
+| 2026-07-30 | Channel BI KPI label | The card labeled `公司数` renders worksheet-level `channel_count`; LianTai GROUP/FIT are two channels, so six does not mean six companies | Decide whether to relabel it `渠道/子表数` or implement an explicit five-company aggregation |
+| 2026-07-30 | Production worker packaging | Local Web/worker separation is live, but the checked-in Compose image does not contain the workstation-only Node/artifact-tool runtime required by the XLSX builder | Package an approved builder runtime and shared output volume before adding/enabling the managed production worker service |
+
+## Superseded For ARR2.0
+
+- ADR-002 controlled Agent orchestration and ADR-003 public source URL are ARR1 history; ADR-004 is authoritative.
+- Agent/MCP Prompt experiments, trace reconciliation and grant-expiry issues do not apply to the ARR2.0 active path.
+- Historical handoffs that describe Web + MCP deployment or Agent credentials are not deployment instructions; use the
+  root and `deploy/` READMEs.
 
 ## Missing Context
 
-- Exact post-commit dispatch mechanism and business-date/cutoff derivation rules are not yet documented.
+- No unresolved monthly business-rule context. ADR-001 defines the dedicated worker and max-included-`ARRIVAL` policy.
diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md
index 57ab8e9..7e830fb 100644
--- a/AGENT_INTEGRATION.md
+++ b/AGENT_INTEGRATION.md
@@ -1,6 +1,8 @@
-# Open Agent API 对接说明
+# ARR1 历史文档 — Open Agent API 对接说明
 
-更新时间:2026-07-27
+> 仅作兼容与迁移审计。ARR2.0 的生产行为以根目录 README 和 ADR-004 为准,不导入、配置或部署此集成。
+
+更新时间:2026-07-29
 
 ## 1. 已完成范围
 
@@ -13,7 +15,7 @@
 - 以 JSONL 输出流事件,便于后续转发到 WebSocket、微信或 CRM;
 - 所有密钥只从环境变量读取。
 
-底层 `agent_integration` 只负责文本 Agent API。Opera XML 远程任务由独立 `arr_processing` 边界建模:ARR 代码先上传私有 OSS,再向 Agent 发送一个不含密钥的 `oss_attachments` 描述对象,由 runtime 的 `fetch_oss_file` 取件;结果只接受经受信 runtime 签名的 path-free 回调。具体处理规则仍只在 `arr-opera-daily-ingest` 确定性 Skill 内。
+底层 `agent_integration` 只负责文本 Agent API。Opera XML 远程任务由独立 `arr_processing` 边界建模:ARR 代码上传源 XML,把 committed 对象设为 `public-read`,再向 Agent 发送含 ARR 生成的无签名 HTTPS URL、但不含密钥的 `oss_attachments` 描述对象,由 runtime 的 `fetch_oss_file` 取件;暂存与输出对象仍为 private。结果只接受经受信 runtime 签名的 path-free 回调。具体处理规则仍只在 `arr-opera-daily-ingest` 确定性 Skill 内。
 
 ## 2. 安装
 
@@ -71,10 +73,21 @@ export DEERFLOW_SESSION_DB="runtime/agent_sessions.sqlite3"
 
 公开事件只包含 `run.started`、`message.delta`、`message.completed`、`run.warning/run.error` 和 `run.end`,不会转发 LangGraph 内部推理、服务器路径、标题中间件或跟踪 metadata。
 
+ARR 生产任务另有受控诊断路径:请求
+`POST /api/open/agent-sessions/{session_id}/messages/stream?include_trace=true`,
+只把外层 `trace` 中的 `run.started`、`task.updated`、`step.updated`、运行终态
+和 `end` 投影为脱敏运维事件。`message.delta`、`message.final`、原始 SSE、Prompt、
+工具参数/结果都不落入任务日志。
+
 调试终端可追加 `--output text`。只有排查协议问题且输出位置受控时才使用 `--output raw-jsonl`;原始流可能包含内部推理和服务器运行信息,不得直接转发给最终用户或写入普通业务日志。
 
 `conversation-id` 和 `external-subject-id` 应使用稳定、不含姓名或证件信息的内部 ID。上游有消息 ID 时务必传 `--message-id`;同一个 ID 会生成同一个幂等键,便于安全重发。
 
+注意:2026-07-29 的真实接口探针确认,`/messages/stream?include_trace=true`
+对完全相同的消息幂等键仍会创建新的 `run_id`,不能把它当作历史回放或服务端
+去重接口。生产代码只 POST 一次并继续消费同一条连接;拿到 `run.started` 后绝不
+为了“补日志”再次提交。
+
 ## 6. 非流式 Run
 
 ```bash
@@ -143,6 +156,7 @@ with SQLiteSessionStore(config.session_db) as store:
             "你好",
             message_id="crm-message-001",
             metadata={"source": "crm"},
+            include_trace=True,
         ):
             print(event.to_dict())
 ```
@@ -151,7 +165,7 @@ with SQLiteSessionStore(config.session_db) as store:
 
 当前 Open API 契约没有附件上传或文件登记接口。远端 Agent 无法访问调用机上的 `/absolute/input.xml`,所以不能把本地路径写进消息后假定 Agent 能读取。
 
-当前已实现 `arr_processing` 的 job/run 关联、有界重试、直接 OSS 输入描述、runtime 结果发布、独立 HMAC 签名、OSS 输出重取、ARR 独立复验和 PostgreSQL 原子落库。输入 message 符合 `arr_opera_daily_program_input.schema.json`:只含 bucket、endpoint、精确 object key、哈希、字节数与任务 ID,不含 OSS AccessKey、XML 正文、签名 URL 或本机路径。`fetch_oss_file` 的平台 credential provider 独立持有读权限。
+当前已实现 `arr_processing` 的 job/run 关联、有界重试、直接 OSS 输入描述、runtime 结果发布、独立 HMAC 签名、OSS 输出重取、ARR 独立复验和 PostgreSQL 原子落库。输入 message 符合 `arr_opera_daily_program_input.schema.json`:含 bucket、public endpoint、精确 object key、ARR 生成的无签名 HTTPS URL、哈希、字节数与任务 ID,不含 OSS AccessKey、XML 正文或本机路径。`fetch_oss_file` 只接收该公网 URL 和文件名,不使用 OSS Provider。
 
 结果不从 `final_content`、`present_files` 或 Agent 本地路径取回。受信 runtime adapter 把产物发布到私有 OSS 交换前缀,然后签名回调 ARR;ARR 从 OSS 重新下载并复验后才入库。回写接口、状态码、ID 对应和部署变量见 `AGENT_WRITEBACK_CONTRACT.md`;实现边界见 `arr_processing/README.md`。
 
diff --git a/AGENT_WRITEBACK_CONTRACT.md b/AGENT_WRITEBACK_CONTRACT.md
index de40d48..a2670b7 100644
--- a/AGENT_WRITEBACK_CONTRACT.md
+++ b/AGENT_WRITEBACK_CONTRACT.md
@@ -1,4 +1,6 @@
-# Super Agent 结果回写与业务落库合同
+# ARR1 历史文档 — Super Agent 结果回写与业务落库合同
+
+> 仅作兼容与迁移审计。ARR2.0 没有 Agent 回调路由,本地工件直接进入通用 `DeliveryEnvelope` 验证边界。
 
 更新时间:2026-07-28
 
@@ -28,7 +30,7 @@ Content-Type: application/json
   -> 最后切换 current_daily_versions
 ```
 
-输入方向是 ARR 代码直接上传 XML 到私有 OSS,然后在 Agent message 中传入一个 `oss_attachments` 描述对象。对象只含 bucket、endpoint、object key、哈希、字节数与任务 ID;OSS AccessKey 只存在 ARR 进程与 `fetch_oss_file` 的 credential provider 中。
+输入方向是 ARR 代码直接上传 XML,把 committed 源对象设为 `public-read`,然后在 Agent message 中传入一个 `oss_attachments` 描述对象。对象包含 bucket、public endpoint、object key、ARR 生成的无签名 HTTPS URL、哈希、字节数与任务 ID;`fetch_oss_file` 原样读取该公网 URL,不使用 OSS Provider。ARR 的写入 AccessKey 只存在 ARR 进程中。
 
 结果方向采用 runtime 主动回调,不依赖 Open API 的 `final_content`。后者在真实服务上可能为 `null`,也无法让 ARR 读取 Agent 沙箱的本地文件。
 
@@ -65,7 +67,7 @@ Agent 的业务失败也可以回写,但只记录失败 delivery、工件和
 - `ARR_OBJECT_PREFIX`、`ARR_AGENT_OUTPUT_PREFIX`;
 - OSS SDK 的 RAM/STS 凭据环境变量。
 
-HMAC 密钥不能复用 `DEERFLOW_OPEN_API_KEY`。OSS bucket 必须配置服务端加密并保持 versioning Off;允许部署方明确选择 `public-read` bucket,但永远拒绝 `public-read-write`。每个 ARR 新对象都会显式设置 private ACL,因此不会继承 bucket 的公开读取权限。
+HMAC 密钥不能复用 `DEERFLOW_OPEN_API_KEY`。本集成要求 OSS bucket 为用户确认的 `public-read`、配置服务端加密并保持 versioning Off;`private` 与 `public-read-write` bucket 都会被 readiness 拒绝。只有 committed 源 XML 显式设置 `public-read` ACL,暂存对象和处理输出显式设置 `private` ACL。
 
 本机部署把 OSS AccessKey、Agent Open API key 和回调 HMAC 保存到 macOS 登录钥匙串,`agent-writeback.env` 只保存非敏感路由。LaunchAgent 固定使用 Keychain account `arr-web`,读取的 service 分别为 `com.chillishark.arr.oss-access-key-id`、`com.chillishark.arr.oss-access-key-secret`、`com.chillishark.arr.deerflow-open-api-key` 和 `com.chillishark.arr.agent-result-hmac`;任何交接文档、日志和项目文件都不得写出实际值。
 
diff --git a/CHECKSUMS.sha256 b/CHECKSUMS.sha256
index 2347ae7..1872185 100644
--- a/CHECKSUMS.sha256
+++ b/CHECKSUMS.sha256
@@ -25,8 +25,8 @@ bf5ba99740784683636bd2bff1b985061406ee2ff4cbc19c2f19116ef5dab3b5  arr-opera-dail
 6b31a6eeb7984825a1456b16f490db14071ff4486523f32c40fd539e3c6225dc  arr-opera-daily-ingest/scripts/validate_daily.py
 aa95f74d6997ebc5e0a678529f59a2c3bd3de1c15855b997745ffc9f9b96aae4  arr-opera-daily-ingest.zip
 aa95f74d6997ebc5e0a678529f59a2c3bd3de1c15855b997745ffc9f9b96aae4  arr-opera-daily-ingest.skill
-2c9c8f7aea20cc7a2814a832436607cac5785b93bea305cad4654b2529316dc1  prompts/arr_opera_daily_main_agent_prompt.md
+ce1bb9b13f46ec1e42eb11b4171e9c710aa49bc0959475a9d4cc90a483cf2c7c  prompts/arr_opera_daily_main_agent_prompt.md
 df20230c96f5df6d6921e0f1d15ab3076c99545547c239648347191408ab668c  prompts/arr_opera_daily_agent_result.schema.json
 267a902ce554c6b2df064387b2dfb86ca3db1000b7061e04f5c2bfbd49653f72  prompts/arr_opera_daily_profile_output.schema.json
-dd8f9e9c2d18522aa448d4ee5d3262e62531a45b4949f070336960a8590e1b11  prompts/arr_opera_daily_program_input.schema.json
-f0c0f474e9a7deea1a1d7c3bab7fd32b6cc8bf6aec91fb04ea4ea0bb7ec867f3  DATA_PROCESSING_HANDOFF.md
+298583f81a40927d2f55d2bf07b94c08fc40aff7ff25fb9d5816792e99731315  prompts/arr_opera_daily_program_input.schema.json
+373a7568e459b2a14fb1fc302628783cb22d09afd2ee4f84065a8684d8213cf2  DATA_PROCESSING_HANDOFF.md
diff --git a/DATABASE_SCHEMA_DICTIONARY.md b/DATABASE_SCHEMA_DICTIONARY.md
index 2b67859..71ef6e2 100644
--- a/DATABASE_SCHEMA_DICTIONARY.md
+++ b/DATABASE_SCHEMA_DICTIONARY.md
@@ -75,6 +75,20 @@ ARR 收到并验收的 DeliveryEnvelope。
 
 字段:`source_artifact_id`、`source_kind`(`expected_fixture`/`booking_excel`)、`source_format_version`、可选参考年月、`batch_status`、source/accepted/failed 计数、失败字段和时间线。一个工件只能形成一个批次。
 
+### `booking.current_source_batch`
+
+单行指针,指向公司报表当前使用的完整、已验收 Booking 批次。新工作簿只有在全部人工项确认或删除并完成原子激活后才替换该指针;历史批次保持不可变。
+
+### `booking.extraction_drafts`
+
+上传工作簿的持久化提取草稿。关键字段包括 `draft_id`、私有源工件、`draft_status`(`reviewing`/`activated`/`superseded`)、processor/rule/source 哈希、源行/工作表/Tour Code/提取项计数以及激活后的批次 ID。`reviewing` 草稿不会改变 `current_source_batch`。
+
+### `booking.extraction_draft_items`
+
+一条记录对应一个提取出的房型项目,保留工作表、原行号、item 顺序、Tour Code、酒店原文、房型原始标识、标准/人工房型、数量、源片段和 `automatic` 标志。
+
+`review_status` 为 `confirmed`、`pending` 或 `deleted`:待人工项目保留已经识别到的数量但不进入有效 Booking 房量;人工填写房型并保存后转为 `confirmed`;删除是草稿内软删除,启用时只写入仍为 `confirmed` 的项目。
+
 ### `booking.source_rows`
 
 一行对应原文件中的一行,重复 Group Code 也必须分行保存。
@@ -96,7 +110,7 @@ ARR 收到并验收的 DeliveryEnvelope。
 
 ### `booking.parse_versions`
 
-一条来源行的不可变 Agent 解析版本。
+一条来源行的不可变程序解析版本。
 
 字段:`source_row_id`、`version_no`、`parse_status`(`accepted`/`needs_review`/`failed`)、结果 Schema、processor 名称/版本、规则哈希、输入行哈希、结果哈希、完整已验收 `result_json`、失败字段和验收时间。版本唯一键为 `source_row_id + version_no`。
 
@@ -104,7 +118,7 @@ ARR 收到并验收的 DeliveryEnvelope。
 
 ### `booking.room_items`
 
-解析后可按 Group Code 查询的房型项目;一条来源行可拆出多项。
+程序提取并确认后可按 Group Code 查询的房型项目;一条来源行可拆出多项。
 
 | 字段 | 类型 | 含义 |
 |---|---|---|
diff --git a/DATA_PROCESSING_HANDOFF.md b/DATA_PROCESSING_HANDOFF.md
index 2161c24..a9b53f0 100644
--- a/DATA_PROCESSING_HANDOFF.md
+++ b/DATA_PROCESSING_HANDOFF.md
@@ -1,14 +1,17 @@
-# ARR 数据处理与数据库交接
+# ARR1 历史文档 — ARR 数据处理与数据库交接
 
-更新时间:2026-07-28  
+> 当前行为请以 ARR2.0 根目录 README、ADR-004 和 current-state 项目记忆为准。
+
+更新时间:2026-07-30
 适用:另一个对话继续训练/调试日报 Skill、月报程序或公司 10 日报表程序。
 
 ## 1. 最终处理链
 
 ```text
 用户上传 Opera XML
-  → ARR 存入私有 OSS
-  → SuperAgent 获取 XML
+  → ARR 存入 OSS,仅将 committed 源 XML 设为 public-read
+  → ARR 在 PROGRAM_INPUT v3 写入无签名公网 HTTPS URL
+  → SuperAgent 用 fetch_oss_file 原样获取 XML
   → Agent 调用 arr-opera-daily-ingest
   → Skill 生成日报 XLSX、result.json、structured-result.json
   → ARR 获取正式工件并建立 DeliveryEnvelope
@@ -180,4 +183,4 @@ arr_ingestion 独立验收后事务落库。不要输出数据库密码、OSS 
 
 ## 9. 生产缺口
 
-数据库/程序读路径已验收;真实生产仍缺最小权限账号、正式私有 OSS provider、SuperAgent 文件获取与回调验签,以及生产级共享幂等/恢复演练。
+数据库/程序读路径已验收;真实生产仍缺最小权限账号、public-read 源 XML 的保留期/访问日志控制、SuperAgent `.xml`/`application/xml` 获取验收与回调验签,以及生产级共享幂等/恢复演练。`fetch_oss_file` 使用 ARR 生成的公网 URL,不配置 OSS Provider;暂存与处理输出对象仍为 private。
diff --git a/Dockerfile b/Dockerfile
index dc3677c..e840286 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,9 +9,7 @@ WORKDIR /app
 
 COPY requirements*.txt ./
 RUN python -m pip install --upgrade pip \
-    && python -m pip install \
-        --requirement requirements.txt \
-        --requirement requirements-arr-mcp.txt
+    && python -m pip install --requirement requirements.txt
 
 RUN groupadd --system arr \
     && useradd --system --gid arr --create-home arr
@@ -23,6 +21,6 @@ RUN mkdir -p /app/runtime /app/outputs \
 
 USER arr
 
-EXPOSE 8765 8890
+EXPOSE 8765
 
 CMD ["python", "-m", "arr_web.run", "--host", "0.0.0.0", "--port", "8765", "--enable-processing"]
diff --git a/README.md b/README.md
index 4b758e9..1770188 100644
--- a/README.md
+++ b/README.md
@@ -1,52 +1,105 @@
-# Wyndham ARR
+# ARR 2.0:程序化 XML 入库
 
-ARR 是一套受控的 Opera 日报处理与经营数据应用。当前可部署链路为:
+ARR 2.0 是独立于原 ARR 工程的程序化版本。用户只上传一次 Opera `RES_DETAIL` XML,后续处理、校验、工件保存和 Finance 入库全部由 ARR 自己完成;生产路径不调用 Agent,不暴露 MCP,也不需要 prompt、`fetch_oss_file`、submission grant 或公网源文件地址。
+
+## 处理链路
 
 ```text
-业务页面上传 XML
-  -> ARR 写入私有 OSS 并登记任务
-  -> ARR 调用 SuperAgent Open API
-  -> SuperAgent 读取 OSS XML 并执行确定性日报 Skill
-  -> SuperAgent 调用 ARR MCP 的 arr_submit_processing_result
-  -> ARR 独立复验源 XML 后事务落库
-  -> 日报历史、月度投影和渠道看板从数据库读取
+浏览器上传 XML
+  -> ARR 将源 XML 作为 private 不可变对象写入 OSS
+  -> ARR 登记 processing run / attempt,并切换为 running
+  -> 固定版本 process_daily.py 在隔离临时目录生成完整 JSON/XLSX
+  -> ARR 将结果工件作为 private 不可变对象写入 OSS
+  -> DeliveryValidator 校验 Schema、哈希、行数、业务恒等式和独立日报复验
+  -> PostgresIngestionRepository 在一个事务中写入/激活 Finance 版本
+  -> 写入 arr.daily_version_committed outbox 事件并返回 succeeded / failed 的终态回执
+  -> 独立 monthly worker 领取事件
+  -> 从数据库内 ARRIVAL 派生月份与“更新至”日期
+  -> 生成并校验月报,原子登记 reporting 元数据和可下载工件
 ```
 
-## 公网部署
+上传接口仍返回 HTTP `202` 以兼容现有页面,但请求会等到校验和数据库提交结束,因此响应体里的任务状态已经是 `succeeded` 或 `failed`,不是“远端已接收”。
 
-仓库提供 Docker 镜像与 Docker Compose + Caddy 部署入口。Dockerfile
-默认 Web CMD 和 Compose Web command 都显式使用 `--enable-processing`
-打开 XML 上传处理;Compose 另使用 `--secure-cookies`、HTTPS、Web Basic Auth
-和 MCP Bearer 保护公网服务。
+## 保留的安全边界
 
-完整步骤见 [`deploy/README.md`](deploy/README.md)。部署后必须确认:
+- 源 XML、日报、两份 JSON 和异常清单都按 SHA-256、字节数、MIME 和对象身份校验。
+- 成功结果由独立 `validate_daily.py` 再验一次;验证失败不会写入半成品 Finance 版本。
+- 同一 delivery 的重放保持幂等;用户重新上传会创建新的 job,并按现有业务日期版本规则在成功后安全替换当前版本。
+- 数据库只对可识别的瞬时并发错误做最多四次事务重试。这不是模型或工具重试。
+- 当前 bucket 可以是 `private` 或 `public-read`,但 ARR 2.0 写出的每个对象 ACL 都是 `private`;`public-read-write` 和已启用/暂停 versioning 会被拒绝。
 
-- `GET /api/health` 的 `database_ready=true`;
-- `GET /api/health` 的 `processing_ready=true`;
-- MCP 未认证请求返回 `401`;
-- 认证后的 MCP 工具发现只返回 `arr_submit_processing_result`。
+## 本地启动
 
-应用源码默认仍为 fail-closed;直接运行 `python -m arr_web.run` 不会自动开启上传处理。
-运维若覆盖镜像 CMD,必须在 Web 命令中保留 `--enable-processing`。
-
-## 主要入口
-
-- `arr_web`:上传、任务历史、月度数据与渠道看板 Web/API;
-- `arr_mcp`:SuperAgent 结构化处理结果的单工具 MCP 入库网关;
-- `arr-opera-daily-ingest`:Opera XML 的确定性日报处理 Skill;
-- `database`:PostgreSQL 15+ schema、增量迁移和 MCP 契约;
-- `tests`:单元、契约和无隐私合成链路测试。
-
-## 当前能力边界
-
-- XML 上传、私有 OSS 保存、SuperAgent 任务发起和 MCP 直接落库代码已接线;
-- 正式公网环境仍需部署者注入数据库、OSS、SuperAgent 和 MCP Secret,并完成一次无隐私 XML 竖切验收;
-- “日报落库后自动触发月报”与月报 `TOTAL PRICE` 指定公式仍未实现,本部署配置不会开启月报/公司报表写入端点,也不会把远端 Agent `success` 误报为数据库已提交。
-
-## 本地测试
+建议使用 Python 3.12:
 
 ```bash
-python3 -m unittest discover -s tests -v
+python -m venv .venv
+.venv/bin/python -m pip install -r requirements.txt
+cp .env.example .env.local
 ```
 
-项目不会从仓库内自动加载 `.env`。真实凭据、业务 XML/XLSX、数据库文件、运行输出和虚拟环境均不得提交。
+将 `.env.local` 中的数据库、OSS 和凭据注入当前 shell 后启动。Web 还必须从运行时 Secret
+注入 `ARR_WEB_USERNAME` 和至少 12 位的 `ARR_WEB_PASSWORD`;任一缺失都会拒绝启动,真实值不得提交到仓库:
+
+```bash
+.venv/bin/python -m arr_web.run \
+  --host 127.0.0.1 \
+  --port 8765 \
+  --enable-processing \
+  --enable-monthly-generation \
+  --enable-company-reports \
+  --node-binary /absolute/path/to/node \
+  --artifact-tool-module /absolute/path/to/artifact_tool.mjs
+```
+
+另开一个进程启动月报消费者:
+
+```bash
+.venv/bin/python -m monthly_reports.worker \
+  --db-config /absolute/path/to/booking-test-db.env \
+  --node-binary /absolute/path/to/node \
+  --artifact-tool-module /absolute/path/to/artifact_tool.mjs
+```
+
+浏览器访问 `http://127.0.0.1:8765` 后会先进入 ARR 登录页。登录后,`GET /api/health` 中相关 readiness
+均为 `true` 才表示页面处理和下载能力可用;未登录的容器只使用无详情的 `GET /healthz` readiness。
+worker 是独立无端口进程,应由进程管理器单独保活。
+
+当前测试库权威结构为 `database/008_arr_mvp_v1_rebuild.sql` 加 009–015 增量迁移。012 只增加月报发布元数据、Finance 日版本 lineage 和受控本地工件身份,不复制月报业务/住客行;013 将用户上传的 XML 文件名独立保存为任务来源信息,内部源工件仍统一命名为 `source.xml`;014/015 增加 Booking 当前整表指针以及可编辑的 Excel 提取草稿。ARR 2.0 使用原有 `artifact_callback` 通用工件交付表;不会读写 009/010 的 grant/MCP submission 表。
+
+## Booking Excel 房型提取
+
+“公司渠道明细”页可以上传原始 `.xlsx`,程序从同一工作表的 `Tour Code` 与精确泰文表头 `โรงแรม` 提取房型记录。提取结果先进入草稿,不会直接覆盖当前 Booking 数据:
+
+- Tour Code 会删除制表符、换行和空格;同一 Tour Code 以物理位置最后一行为准,最后一行明确写明取消时移除,单元格底色不代表取消。
+- 只分析酒店文本圆括号内的房型信息。`【房型】` 后面的整数是数量;未单独写数量时按 1。
+- `U-TWN`/`U-DBL` 后带任意数字或小数仍分别归一为 `U-TWN`/`U-DBL`;`高级房TWN`、`高级房DBL` 分别归一为 `TWN`、`DBL`。
+- 无法确认的中括号内容保留原始标识、保留已识别数量并标为“待人工确认”;没有中括号的房型文本也进入人工确认。
+- 多个房型拆成多条记录,重复房型不在提取阶段合并。附加费、儿童早餐和导游房等非客房项目忽略。
+- 自动识别与人工记录都可修改或删除;待人工项目确认前不计入有效房量。全部待人工项确认或删除后,才可一次性启用整份工作簿;重新提取原文件会按源文件重建草稿。
+
+对应持久化迁移是 `database/014_booking_current_source_batch.sql` 和 `database/015_booking_excel_review_drafts.sql`。
+
+## 测试
+
+```bash
+.venv/bin/python -m unittest discover -s tests -p 'test_*.py' -v
+```
+
+`tests/test_arr_programmatic.py` 是关键竖切:它使用真实固定处理器、不可变对象存储、独立验证器和入库 repository 覆盖成功与业务失败。
+
+## 部署
+
+生产配置见 [deploy/README.md](deploy/README.md)。当前 Compose 模板仍只打包 `web` 与 `caddy`,Caddy 负责
+HTTPS,Web 负责应用登录和会话;没有 MCP 端口、MCP 域名或 Agent Secret。月报 worker 必须作为独立进程部署,
+并使用同一数据库、共享输出卷以及已经打包 Node/artifact-tool 的运行镜像;当前本地工作站已按这一方式运行。
+
+## 当前月报行为
+
+- 用户只上传 XML,页面不再要求月份、截止日或单独点击“生成月报”。
+- worker 只消费成功提交的日报事件,使用纳入月报数据的最大 `ARRIVAL` 作为“更新至”;XML 文件名和当前时间都不参与推导。
+- 月报版本、lineage、渠道行数和两个工件身份持久化到 `reporting` schema;列表和下载由这些元数据驱动。
+- 月报页可见时自动同步发布记录,新版本直接加入列表,不需要点击“刷新”。
+- 每条数据行的 `TOTAL PRICE` 都是 `REAL PRICE × NIGHTS × NO_OF_ROOMS` 的 Excel 公式(当前列布局为 `=R[row]*C[row]*G[row]`)。
+
+原工程 `/Users/chillishark/ARR项目0727` 未被修改,可继续作为 ARR 1.x 回滚基线。
diff --git a/SUPERAGENT_PROFILE_V3_MANUAL_CONFIG.md b/SUPERAGENT_PROFILE_V3_MANUAL_CONFIG.md
index 62be25c..1f38d4c 100644
--- a/SUPERAGENT_PROFILE_V3_MANUAL_CONFIG.md
+++ b/SUPERAGENT_PROFILE_V3_MANUAL_CONFIG.md
@@ -1,4 +1,6 @@
-# ARR数据处理Agent Profile v3 手工配置
+# ARR1 历史文档 — ARR 数据处理 Agent Profile v3 手工配置
+
+> ARR2.0 不使用此配置;仅保留为历史迁移证据。
 
 ## 1. 应用设置
 
diff --git a/SUPERAGENT_PROFILE_V4_MCP_CONFIG.md b/SUPERAGENT_PROFILE_V4_MCP_CONFIG.md
index e35500e..9b5f3da 100644
--- a/SUPERAGENT_PROFILE_V4_MCP_CONFIG.md
+++ b/SUPERAGENT_PROFILE_V4_MCP_CONFIG.md
@@ -1,4 +1,6 @@
-# ARR数据处理Agent v4 + ARR MCP 手工配置
+# ARR1 历史文档 — ARR 数据处理 Agent v4 + ARR MCP 手工配置
+
+> ARR2.0 不使用此配置;默认 Compose 没有 MCP 服务或公网 MCP 入口。
 
 本清单用于公网无 PII 联调。开始前应按 `deploy/README.md` 部署 ARR Web/MCP,使用长期受控 HTTPS
 域名,并把 MCP bearer 放入平台 Secret 管理。
diff --git a/agent_integration/client.py b/agent_integration/client.py
index e5e89b9..f34000e 100644
--- a/agent_integration/client.py
+++ b/agent_integration/client.py
@@ -142,6 +142,7 @@ class OpenAgentAPIClient:
         *,
         idempotency_key: Optional[str] = None,
         metadata: Optional[Dict[str, Any]] = None,
+        include_trace: bool = False,
     ) -> Iterator[OpenAgentEvent]:
         payload = self._message_payload(message, idempotency_key=idempotency_key, metadata=metadata)
         path = f"/api/open/agent-sessions/{self._path_segment(session_id)}/messages/stream"
@@ -149,6 +150,7 @@ class OpenAgentAPIClient:
             with self._client.stream(
                 "POST",
                 self._url(path),
+                params={"include_trace": "true"} if include_trace else None,
                 headers=self._headers(include_csrf=True, accept="text/event-stream"),
                 json=payload,
                 timeout=self.timeout,
@@ -332,4 +334,3 @@ class OpenAgentAPIClient:
             response_text,
             request_id=request_id,
         )
-
diff --git a/agent_integration/service.py b/agent_integration/service.py
index b76bc0b..1c84dfe 100644
--- a/agent_integration/service.py
+++ b/agent_integration/service.py
@@ -67,6 +67,7 @@ class OpenAgentService:
         message_id: Optional[str] = None,
         external_subject_id: Optional[str] = None,
         metadata: Optional[Dict[str, Any]] = None,
+        include_trace: bool = False,
     ) -> Iterator[OpenAgentEvent]:
         conversation_id = self._required("conversation_id", conversation_id)
         message = self._message(message)
@@ -81,6 +82,7 @@ class OpenAgentService:
             message,
             idempotency_key=idempotency_key,
             metadata=self._metadata(metadata),
+            include_trace=include_trace,
         )
 
     def send_message(
diff --git a/arr_database.py b/arr_database.py
new file mode 100644
index 0000000..09a0f80
--- /dev/null
+++ b/arr_database.py
@@ -0,0 +1,52 @@
+"""Shared controlled PostgreSQL connection helper for ARR processes."""
+
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+from typing import Any, Callable, Dict, Optional
+
+
+def read_controlled_database(path: Path) -> Dict[str, object]:
+    values: Dict[str, str] = {}
+    for raw in path.read_text(encoding="utf-8").splitlines():
+        line = raw.strip()
+        if not line or line.startswith("#") or "=" not in line:
+            continue
+        key, value = line.split("=", 1)
+        values[key.strip()] = value.strip().strip('"').strip("'")
+    required = (
+        "ARR_DB_HOST",
+        "ARR_DB_PORT",
+        "ARR_DB_USER",
+        "ARR_DB_PASSWORD",
+        "ARR_DB_NAME",
+    )
+    if any(not values.get(key) for key in required):
+        raise ValueError("controlled database configuration is incomplete")
+    return {
+        "host": values["ARR_DB_HOST"],
+        "port": int(values["ARR_DB_PORT"]),
+        "user": values["ARR_DB_USER"],
+        "password": values["ARR_DB_PASSWORD"],
+        "dbname": values["ARR_DB_NAME"],
+    }
+
+
+def controlled_connect(
+    config_path: Path,
+    driver_path: Optional[Path],
+) -> Callable[[str], Any]:
+    if driver_path is not None:
+        resolved = driver_path.expanduser().resolve()
+        if not resolved.is_dir():
+            raise ValueError("database driver path is unavailable")
+        sys.path.insert(0, str(resolved))
+    import psycopg  # type: ignore[import-not-found]
+
+    parameters = read_controlled_database(config_path.expanduser().resolve())
+
+    def connect(_dsn: str) -> Any:
+        return psycopg.connect(**parameters, autocommit=False)
+
+    return connect
diff --git a/arr_ingestion/README.md b/arr_ingestion/README.md
index 2f8984d..60e5525 100644
--- a/arr_ingestion/README.md
+++ b/arr_ingestion/README.md
@@ -1,117 +1,21 @@
 # ARR validated daily ingestion
 
-This package is the ARR-owned boundary between a SuperAgent delivery and PostgreSQL. It does not run the Agent or generate monthly reports. Production object materialization is supplied by the separate `arr_storage.ManagedObjectStore` boundary.
-
-It supports two result-delivery modes:
-
-- `artifact_callback`: the existing signed callback with Agent-produced OSS artifacts;
-- `direct_mcp`: one complete structured payload submitted through the future ARR MCP gateway, independently replayed from the original private XML before Finance activation.
-
-## Authority boundary
+This package is the authoritative validate-before-write boundary for ARR2.
 
 ```text
-private object store
+committed source/output objects
   -> DeliveryEnvelope 1.0
-  -> DeliveryValidator (hash/size/MIME + v3 contract + independent replay)
+  -> DeliveryValidator (identity + strict v3 reconciliation + validate_daily.py)
   -> PostgresIngestionRepository (one atomic transaction)
-  -> current retained facts + privacy-minimized outbox
+  -> current Finance facts + privacy-minimized outbox
 ```
 
-Only ARR calls this package and owns `ARR_DATABASE_URL`. SuperAgent/Agent receives opaque job/file handles and never receives database credentials.
+`JobRegistration` records source/processor/rule/attempt identity before processing. Controlled processor failures use a
+complete failed delivery and retain auditable outcomes without changing the current Finance version. Timeouts,
+process-start errors, malformed artifacts or pre-commit validation errors use explicit lifecycle failure state.
 
-## Direct MCP service boundary
+Only recognized transient PostgreSQL SQLSTATEs reopen the full transaction, at most four attempts. Business, Schema and
+identity errors are never retried. Exact delivery replay is idempotent; changed bytes under the same ID conflict.
 
-The framework-neutral direct service is already implemented; the MCP protocol layer must remain a thin adapter over it:
-
-```python
-from arr_web.direct_ingestion_runtime import compose_oss_direct_ingestion
-
-runtime = compose_oss_direct_ingestion(project_root=project_root)
-try:
-    receipt = runtime.service.submit({
-        "submission_grant": short_lived_attempt_grant,
-        "job_id": job_id,
-        "attempt_no": attempt_no,
-        "payload": frozen_structured_result,
-    })
-finally:
-    runtime.close()
-```
-
-ARR computes the canonical payload SHA-256 and byte size. The caller must not send a duplicate hash field. The payload is limited to 3 MiB, bound to one `job_id`/attempt grant, and accepts only successful, activation-eligible structured-result v3 data. The service then:
-
-1. atomically consumes the hashed short-lived grant and stores a validating ledger row;
-2. materializes the registered `source.xml` from private object storage;
-3. reruns the allowlisted deterministic processor and compares every business fact;
-4. writes the daily version, all outcome rows, channel metrics, current pointer and outbox atomically;
-5. purges the guest-bearing temporary payload at every terminal state;
-6. returns the prior receipt for an exact retry and rejects changed parameters as a conflict.
-
-The public request and receipt contracts are
-`database/contracts/arr-submit-processing-result-v1.schema.json` and
-`database/contracts/arr-submit-processing-result-receipt-v1.schema.json`.
-Migration `010_mcp_result_ingestion.sql` must be applied before this mode is enabled.
-
-## Runtime requirements
-
-- Python 3.9+
-- `openpyxl==3.1.5` through `requirements-data-processing.txt`
-- `psycopg[binary]==3.2.13` through `requirements-arr-ingestion.txt`
-- applied PostgreSQL migration `008_arr_mvp_v1_rebuild.sql`
-- the allowlisted `arr-opera-daily-ingest` Skill and its independent validator
-
-Install the root `requirements.txt`; do not place a real DSN in source, CLI arguments, logs, prompts, envelopes, or test fixtures.
-
-## Required lifecycle
-
-1. ARR stores the uploaded XML as an immutable private object and constructs its `ArtifactRef`.
-2. ARR registers `JobRegistration` before dispatch. The SHA-256 idempotency key identifies one job attempt.
-3. SuperAgent returns artifacts only. ARR builds the strict `DeliveryEnvelope` and serializes it as UTF-8 JSON.
-4. `DeliveryValidator.validate()` materializes objects into private temporary files, recomputes identity, checks processor/rule allowlist and v3 payload invariants, then invokes the independent Skill validator.
-5. `IngestionService.ingest()` calls the PostgreSQL repository only after validation succeeds.
-6. A successful transaction stores all source outcomes, exact Group Code lookup status and ordered channel metrics; it activates the new version and switches `current_daily_versions` at the end.
-7. A deterministic processing failure stores a rejected daily version and all available source outcomes for audit, but never creates a current pointer or changes the last active business facts.
-
-Minimal application wiring:
-
-```python
-from pathlib import Path
-
-from arr_ingestion.postgres import DatabaseConfig, PostgresIngestionRepository
-from arr_ingestion.service import IngestionService
-from arr_ingestion.validation import DeliveryValidator, ProcessorPolicy
-from arr_storage import FilesystemObjectBackend, ManagedObjectStore
-
-policy = ProcessorPolicy(
-    processor_version="3.0.0",
-    rule_set_sha256="",
-    skill_root=Path("/private/runtime/arr-opera-daily-ingest"),
-)
-repository = PostgresIngestionRepository(DatabaseConfig.from_environment())
-object_store = ManagedObjectStore(
-    FilesystemObjectBackend(Path("/private/object-store"))
-)
-service = IngestionService(
-    DeliveryValidator(object_store, policy),
-    repository,
-)
-
-outcome = service.ingest(raw_delivery_envelope)
-```
-
-`FilesystemObjectBackend` is for controlled local tests only. `arr_storage.CloudObjectBackend` exposes the same guarded path to a provider-specific OSS/S3 client port. Only immutable `committed` objects can be materialized, and the ARR validator still recomputes the delivery-declared identity before any database write.
-
-## Transaction and retry semantics
-
-- PostgreSQL target is hard-gated to `booking_test` in the current test deployment.
-- Each write uses a new SERIALIZABLE transaction with local lock/statement timeouts.
-- Same callback bytes are idempotent; same callback ID with different bytes is rejected.
-- Same source/date/processor/rule reuses the immutable version; a different source on the same date creates the next version and supersedes the old current only after all inserts pass.
-- SQLSTATE 40001, 40P01, 55P03 and concurrent 23505 are retried by reopening the entire transaction, at most four attempts. Business validation errors are never retried.
-- Outbox payloads contain only opaque IDs, dates, counts, version numbers, disposition and safe failure codes.
-
-## Verified test-database state
-
-Migration 008 and a privacy-safe 2026-07-27 vertical slice are deployed to the controlled remote test database `:5432/booking_test`. Successful and rejected delivery paths, all-outcome storage, booking lookup, total-price enforcement, current activation and idempotent replay have been tested. The 2026-07-28 full migration from the LAN source also passed independent structure, content-hash and application read-path checks. Exact hashes and counts are recorded in `database/APPLIED_MIGRATIONS.md` and `database/REMOTE_MIGRATION_20260728.md`.
-
-The active controlled configuration is `/path/to/private/booking-test-db.env`. The old LAN configuration `/path/to/private/booking-test-lan-db.env` is retained only for temporary rollback checks. Production remains blocked until ARR has dedicated least-privilege roles, a concrete private OSS provider/bucket configuration in secret management, and the real SuperAgent file/callback contract.
+Migrations 009/010 and direct-submission modules remain historical compatibility surfaces, but ARR2 uses the generic
+artifact-delivery tables with their existing `artifact_callback` default and does not expose an MCP gateway.
diff --git a/arr_ingestion/contracts.py b/arr_ingestion/contracts.py
index f808c77..01d8b6c 100644
--- a/arr_ingestion/contracts.py
+++ b/arr_ingestion/contracts.py
@@ -44,6 +44,7 @@ ARTIFACT_ROLES = {
 }
 ROLE_CONTRACTS = {
     "source_xml": ("opera_xml", ".xml", "application/xml"),
+    "booking_source": ("booking_excel", ".xlsx", XLSX_MIME),
     "daily_report": ("daily_xlsx", ".xlsx", XLSX_MIME),
     "result_json": ("result_json", ".json", "application/json"),
     "structured_result_json": (
@@ -55,6 +56,7 @@ ROLE_CONTRACTS = {
 }
 ARTIFACT_SIZE_LIMITS = {
     "source_xml": 100 * 1024 * 1024,
+    "booking_source": 25 * 1024 * 1024,
     "daily_report": 100 * 1024 * 1024,
     "result_json": 5 * 1024 * 1024,
     "structured_result_json": 50 * 1024 * 1024,
diff --git a/arr_ingestion/postgres.py b/arr_ingestion/postgres.py
index fe41e5b..00ba0ad 100644
--- a/arr_ingestion/postgres.py
+++ b/arr_ingestion/postgres.py
@@ -216,19 +216,34 @@ class PostgresIngestionRepository(IngestionRepository):
     def _ensure_artifact(cursor: Any, reference: ArtifactRef) -> int:
         cursor.execute(
             """
-            SELECT id, byte_size, mime_type
+            SELECT
+                id,
+                artifact_kind,
+                original_filename,
+                sha256,
+                byte_size,
+                mime_type
             FROM ingestion.artifacts
-            WHERE artifact_kind = %s
-              AND sha256 = %s
+            WHERE storage_provider = %s
+              AND bucket_alias = %s
+              AND object_key = %s
+              AND object_version_id IS NULL
             FOR SHARE
             """,
-            (reference.file_kind, reference.sha256),
+            (
+                ARTIFACT_STORAGE_PROVIDER,
+                ARTIFACT_BUCKET_ALIAS,
+                reference.object_key,
+            ),
         )
         row = cursor.fetchone()
         if row:
             if (
-                int(row[1]) != reference.byte_size
-                or (row[2] or "") != reference.mime_type
+                str(row[1]) != reference.file_kind
+                or str(row[2]) != reference.original_filename
+                or str(row[3]) != reference.sha256
+                or int(row[4]) != reference.byte_size
+                or (row[5] or "") != reference.mime_type
             ):
                 raise IngestionError(
                     "ARTIFACT_CONFLICT",
@@ -278,6 +293,127 @@ class PostgresIngestionRepository(IngestionRepository):
             "processing job could not be stored",
         )
 
+    def mark_running(self, job_id: str, attempt_no: int) -> None:
+        self._run_transaction(
+            lambda cursor: self._mark_running(cursor, job_id, attempt_no),
+            "processing job could not be started",
+        )
+
+    def _mark_running(self, cursor: Any, job_id: str, attempt_no: int) -> None:
+        run_id, run_status, attempt_id, attempt_status = self._lock_attempt(
+            cursor, job_id, attempt_no
+        )
+        if run_status in {"accepted", "rejected", "failed", "cancelled"}:
+            raise IngestionError("JOB_TERMINAL", "processing job is already terminal")
+        if attempt_status in {"succeeded", "failed", "cancelled"}:
+            raise IngestionError("JOB_TERMINAL", "processing attempt is already terminal")
+        cursor.execute(
+            """
+            UPDATE ingestion.processing_attempts
+            SET attempt_status = 'running',
+                started_at = COALESCE(started_at, now())
+            WHERE id = %s
+            """,
+            (attempt_id,),
+        )
+        cursor.execute(
+            """
+            UPDATE ingestion.processing_runs
+            SET run_status = 'running',
+                failure_code = NULL,
+                failure_message = NULL,
+                updated_at = now()
+            WHERE id = %s
+            """,
+            (run_id,),
+        )
+
+    def record_failure(self, job_id: str, attempt_no: int, failure_code: str) -> None:
+        if not isinstance(failure_code, str) or re.fullmatch(
+            r"[A-Z][A-Z0-9_]{0,63}", failure_code
+        ) is None:
+            failure_code = "PROCESSING_FAILED"
+        self._run_transaction(
+            lambda cursor: self._record_runtime_failure(
+                cursor, job_id, attempt_no, failure_code
+            ),
+            "processing failure could not be stored",
+        )
+
+    def _record_runtime_failure(
+        self,
+        cursor: Any,
+        job_id: str,
+        attempt_no: int,
+        failure_code: str,
+    ) -> None:
+        run_id, run_status, attempt_id, _attempt_status = self._lock_attempt(
+            cursor, job_id, attempt_no
+        )
+        if run_status == "failed":
+            return
+        if run_status in {"accepted", "rejected", "cancelled"}:
+            raise IngestionError("JOB_TERMINAL", "processing job is already terminal")
+        cursor.execute(
+            """
+            UPDATE ingestion.processing_attempts
+            SET attempt_status = 'failed',
+                failure_code = %s,
+                failure_message = 'programmatic processing failed',
+                finished_at = now()
+            WHERE id = %s
+            """,
+            (failure_code, attempt_id),
+        )
+        cursor.execute(
+            """
+            UPDATE ingestion.processing_runs
+            SET run_status = 'failed',
+                failure_code = %s,
+                failure_message = 'programmatic processing failed',
+                updated_at = now(),
+                finished_at = now()
+            WHERE id = %s
+            """,
+            (failure_code, run_id),
+        )
+        self._insert_outbox(
+            cursor,
+            f"processing-run:{run_id}:failed",
+            "processing_run",
+            run_id,
+            "arr.processing_failed",
+            {
+                "job_id": job_id,
+                "business_date": None,
+                "daily_version_id": None,
+                "failure_code": failure_code,
+            },
+        )
+
+    @staticmethod
+    def _lock_attempt(
+        cursor: Any,
+        job_id: str,
+        attempt_no: int,
+    ) -> Tuple[int, str, int, str]:
+        cursor.execute(
+            """
+            SELECT run.id, run.run_status, attempt.id, attempt.attempt_status
+            FROM ingestion.processing_runs AS run
+            JOIN ingestion.processing_attempts AS attempt
+              ON attempt.processing_run_id = run.id
+             AND attempt.attempt_no = %s
+            WHERE run.run_key = %s
+            FOR UPDATE OF run, attempt
+            """,
+            (attempt_no, job_id),
+        )
+        row = cursor.fetchone()
+        if not row:
+            raise IngestionError("JOB_NOT_FOUND", "processing job was not registered")
+        return int(row[0]), str(row[1]), int(row[2]), str(row[3])
+
     def _register_job(self, cursor: Any, registration: JobRegistration) -> None:
         source_artifact_id = self._ensure_artifact(cursor, registration.source)
         cursor.execute(
@@ -287,6 +423,7 @@ class PostgresIngestionRepository(IngestionRepository):
                 source_artifact_id,
                 requested_processor_version,
                 requested_rule_set_sha256,
+                uploaded_filename,
                 run_status
             FROM ingestion.processing_runs
             WHERE run_key = %s
@@ -309,9 +446,10 @@ class PostgresIngestionRepository(IngestionRepository):
                     source_artifact_id,
                     run_status,
                     requested_processor_version,
-                    requested_rule_set_sha256
+                    requested_rule_set_sha256,
+                    uploaded_filename
                 )
-                VALUES (%s, 'opera_daily', %s, 'queued', %s, %s)
+                VALUES (%s, 'opera_daily', %s, 'queued', %s, %s, %s)
                 RETURNING id
                 """,
                 (
@@ -319,6 +457,7 @@ class PostgresIngestionRepository(IngestionRepository):
                     source_artifact_id,
                     registration.processor_version,
                     registration.rule_set_sha256,
+                    registration.uploaded_filename,
                 ),
             )
             processing_run_id = int(cursor.fetchone()[0])
@@ -327,6 +466,7 @@ class PostgresIngestionRepository(IngestionRepository):
                 int(existing[1]) != source_artifact_id
                 or existing[2] != registration.processor_version
                 or str(existing[3]) != registration.rule_set_sha256
+                or existing[4] != registration.uploaded_filename
             ):
                 raise IngestionError(
                     "JOB_CONFLICT",
@@ -350,7 +490,7 @@ class PostgresIngestionRepository(IngestionRepository):
                         "processing attempt identity conflicts",
                     )
                 return
-            if str(existing[4]) in {
+            if str(existing[5]) in {
                 "accepted",
                 "rejected",
                 "failed",
@@ -1121,6 +1261,9 @@ class PostgresIngestionRepository(IngestionRepository):
                 source.group_code_key,
                 count(DISTINCT source.id)
             FROM booking.source_rows AS source
+            JOIN booking.current_source_batch AS active_source
+              ON active_source.source_batch_id = source.source_batch_id
+             AND active_source.singleton
             JOIN booking.source_batches AS batch
               ON batch.id = source.source_batch_id
              AND batch.batch_status = 'accepted'
diff --git a/arr_ingestion/repository.py b/arr_ingestion/repository.py
index 5d3ee6c..31ad20f 100644
--- a/arr_ingestion/repository.py
+++ b/arr_ingestion/repository.py
@@ -20,6 +20,7 @@ class JobRegistration:
     rule_set_sha256: str
     attempt_no: int
     idempotency_key: str
+    uploaded_filename: Optional[str] = None
 
 
 @dataclass(frozen=True)
@@ -35,6 +36,12 @@ class IngestionRepository(Protocol):
     def register_job(self, registration: JobRegistration) -> None:
         ...
 
+    def mark_running(self, job_id: str, attempt_no: int) -> None:
+        ...
+
+    def record_failure(self, job_id: str, attempt_no: int, failure_code: str) -> None:
+        ...
+
     def commit_delivery(self, delivery: VerifiedDelivery) -> IngestionOutcome:
         ...
 
@@ -81,6 +88,29 @@ class InMemoryIngestionRepository:
                 return
             self._jobs[registration.job_id] = _MemoryJob(registration=registration)
 
+    def mark_running(self, job_id: str, attempt_no: int) -> None:
+        with self._lock:
+            job = self._jobs.get(job_id)
+            if job is None:
+                raise IngestionError("JOB_NOT_FOUND", "processing job was not registered")
+            if attempt_no != job.registration.attempt_no:
+                raise IngestionError("JOB_NOT_FOUND", "processing attempt was not registered")
+            if job.status in {"succeeded", "failed"}:
+                raise IngestionError("JOB_TERMINAL", "processing job is already terminal")
+            job.status = "running"
+
+    def record_failure(self, job_id: str, attempt_no: int, failure_code: str) -> None:
+        with self._lock:
+            job = self._jobs.get(job_id)
+            if job is None:
+                raise IngestionError("JOB_NOT_FOUND", "processing job was not registered")
+            if attempt_no != job.registration.attempt_no:
+                raise IngestionError("JOB_NOT_FOUND", "processing attempt was not registered")
+            if job.status == "succeeded":
+                raise IngestionError("JOB_TERMINAL", "processing job is already terminal")
+            job.status = "failed"
+            job.failure_code = failure_code
+
     def commit_delivery(self, delivery: VerifiedDelivery) -> IngestionOutcome:
         envelope = delivery.envelope
         with self._lock:
diff --git a/arr_ingestion/validation.py b/arr_ingestion/validation.py
index 6aed7c7..6db4ae9 100644
--- a/arr_ingestion/validation.py
+++ b/arr_ingestion/validation.py
@@ -169,6 +169,8 @@ class ProcessorPolicy:
             raise ValueError("processor policy identity is invalid")
         resolved = self.skill_root.resolve()
         object.__setattr__(self, "skill_root", resolved)
+        if not (resolved / "scripts" / "process_daily.py").is_file():
+            raise ValueError("processor executable is unavailable")
         if not (resolved / "scripts" / "validate_daily.py").is_file():
             raise ValueError("processor validator is unavailable")
         if not (resolved / "references" / "价格对照.xlsx").is_file():
diff --git a/arr_processing/README.md b/arr_processing/README.md
index c51866b..f7b4738 100644
--- a/arr_processing/README.md
+++ b/arr_processing/README.md
@@ -1,55 +1,13 @@
-# ARR remote processing orchestration
+# ARR2 deterministic processing
 
-`arr_processing` is the ARR-owned orchestration boundary between a registered XML source and the existing validated ingestion pipeline. The runner never sees an OSS credential, local Agent path, XML bytes, guest data or database credential. The input-message builder resolves one ARR-owned committed object and emits only the non-secret OSS routing that the existing `fetch_oss_file` provider needs.
+The active ARR2 path uses two modules:
 
-## Request
+- `policy.py` loads the frozen processor version and rule-set SHA-256 from `arr-opera-daily-ingest`.
+- `local.py` runs `process_daily.py` in an isolated directory with a bounded timeout, requires process exit status to
+  agree with both JSON artifacts, and permits only basename-confined declared XLSX outputs.
 
-`ProcessingRequest` serializes exactly six values as canonical JSON:
+The caller stores the success/failure artifacts and passes a canonical `DeliveryEnvelope` to `arr_ingestion`. Processor
+stdout/stderr, temporary paths and source bytes never enter the public response or database.
 
-- contract `arr-opera-daily-request-1`;
-- opaque `job_id` and `source_file_id`;
-- attempt number;
-- approved processor version and rule-set SHA-256.
-
-The database request remains the six-field opaque identity. Immediately before dispatch, `OssProcessingMessageBuilder` resolves the registered immutable source and builds `arr-opera-daily-program-input-2`: one `oss_attachments` item containing the canonical filename, bucket, endpoint host, exact object key, SHA-256 and byte size, plus one short-lived attempt-bound `arr_submit_processing_result` grant. The grant is excluded from `ProcessingRequest.to_dict()`, its canonical hash and repr; it exists only in the remote program message. No AccessKey, XML bytes, URL, local path, database credential or MCP transport bearer enters that message. `OpenAgentProcessingTransport` sends the canonical JSON through the text Session/Run API with a stable dispatch idempotency key.
-
-## Runner semantics
-
-`ProcessingRunner` reserves one request identity, correlates one remote run, and supports start, bounded polling, cancellation and separately delivered callbacks. Retryable 408/429/5xx/network failures reuse the same idempotency key. A 409 is accepted only if the shared ledger already has the exact correlated run; an uncorrelated active run remains a safe retryable error.
-
-The in-memory ledger is for tests/local slices. `PostgresProcessingState` is the production-facing implementation against migration 008: reservation identity, `remote_run_id`, status and callback replay are read from the shared ARR processing tables rather than process memory. A submission that is rejected, exhausts its transport retries, or returns an invalid start response is recorded as a terminal failed attempt/run; an identical replay is not silently submitted again.
-
-Remote `success` is not an ingestion decision. Empty `final_content`, unsigned JSON, free-form text, the old internal finalizer object with `local_path`, a mismatched run, or a terminal remote failure can never construct a `DeliveryEnvelope`.
-
-## Authenticated result
-
-The trusted runtime/callback adapter—not the Agent—must register each generated file and produce the path-free result defined by `prompts/arr_opera_daily_processing_result.schema.json`:
-
-- contract `arr-opera-daily-result-1`;
-- delivery/job/attempt/run correlation;
-- processor, rule-set and result Schema identities;
-- business success/failure shape;
-- opaque handles plus filename, SHA-256, exact byte size and MIME for `daily_report`, `result_json`, `structured_result_json` and `exception_report`.
-
-`SignedResultCodec` authenticates this payload with HMAC-SHA256, key ID, UTC issue time and nonce. Keys must be at least 32 bytes, injected from platform secret management, rotated by key ID, and unavailable to the Agent. The default acceptance window is ten minutes. Callback replay is idempotent only for identical signed bytes; the same delivery ID with different signed bytes is a conflict.
-
-## Output registration
-
-`ProcessingOutputRegistrar` reads every opaque output handle through a controlled `RemoteFilePort`. The deployed implementation, `PrefixRemoteFilePort`, resolves a handle only below `ARR_AGENT_OUTPUT_PREFIX` in the configured private OSS bucket. For every handle it:
-
-1. materializes into a fresh private directory with the role size cap;
-2. rejects missing, symlinked, non-regular, oversized or hash-mismatched bytes;
-3. uploads through the staged/committed `ManagedObjectStore` path;
-4. converts only committed identities to the strict ARR `DeliveryEnvelope 1.0`.
-
-ARR then runs the existing `DeliveryValidator` and PostgreSQL transaction. The signed remote result never bypasses independent replay and never writes Finance facts by itself.
-
-## Runtime writeback adapter
-
-`python -m arr_processing.runtime_writeback` is the trusted post-Skill adapter. It accepts only an approved output root and the frozen `FROZEN_AGENT_RESULT`, rejects symlinks/path escape and filename mismatches, rehashes all files, publishes them under deterministic opaque handles, creates a stable delivery ID, signs the canonical result with the independent callback HMAC key, and posts the exact same signed bytes on retryable failures.
-
-The runtime adapter receives OSS and callback credentials from deployment secret injection. They are not placed in the Agent prompt or frozen result. Its contract and required variables are documented in [`AGENT_WRITEBACK_CONTRACT.md`](../AGENT_WRITEBACK_CONTRACT.md).
-
-## Current production boundary
-
-The text Open API submit/get/cancel contract, direct OSS attachment-shaped input, PostgreSQL callback state, OSS V2 output exchange, runtime-side signer/callback and ARR-side independent validation/commit path are implemented and tested. Deployment still needs an API-enabled published Agent Profile, the actual OSS runtime permissions, an independent HMAC key, a public callback URL and invocation of the writeback adapter after the Skill finishes. Live end-to-end connectivity must not be claimed until both readiness flags are true and one non-sensitive vertical-slice job has committed.
+Older remote-run, signature and callback modules remain as ARR1 compatibility/audit code. The ARR2 Web entrypoint does
+not import them, root requirements do not install their HTTP client, and Compose exposes no corresponding service.
diff --git a/arr_processing/agent_trace.py b/arr_processing/agent_trace.py
new file mode 100644
index 0000000..fce3d9f
--- /dev/null
+++ b/arr_processing/agent_trace.py
@@ -0,0 +1,469 @@
+"""Privacy-minimized persistence and projection for SuperAgent trace SSE events."""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import threading
+from datetime import datetime, timezone
+from hashlib import sha256
+from pathlib import Path
+from typing import Any, Dict, List, Mapping, Optional, Protocol, Union
+
+from agent_integration.client import OpenAgentEvent
+from arr_ingestion.contracts import OPAQUE_ID_RE
+
+
+TRACE_RECORD_VERSION = "arr-agent-trace-event-1"
+MAX_TRACE_EVENTS = 10_000
+MAX_TRACE_FILE_BYTES = 8 * 1024 * 1024
+MAX_TRACE_SUMMARY_CHARS = 1_200
+
+_LEVELS = frozenset({"info", "success", "warning", "error"})
+_CODE_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,95}$")
+_SAFE_DETAIL_KEYS = frozenset(
+    {
+        "attempt_no",
+        "remote_run_id",
+        "remote_status",
+        "trace_event",
+        "invalid_records",
+        "capture_error",
+    }
+)
+_SENSITIVE_ASSIGNMENT_RE = re.compile(
+    r"(?i)\b(submission[_ -]?grant|access[_ -]?key(?:[_ -]?(?:id|secret))?|"
+    r"api[_ -]?key|secret|token|password|authorization|cookie)\b"
+    r"\s*[:=]\s*(?:[\"']?)[^\s,;}\]]+"
+)
+_BEARER_RE = re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]+")
+_URL_RE = re.compile(r"(?i)\b(?:https?|oss)://[^\s<>{}\[\]\"']+")
+_PATH_RE = re.compile(
+    r"(?\r\n]{1,240}>")
+
+
+class AgentTraceStoreError(RuntimeError):
+    """Raised when the local trace store cannot safely persist a record."""
+
+
+class AgentTraceSink(Protocol):
+    def append(self, job_id: str, record: Mapping[str, Any]) -> None:
+        ...
+
+
+class AgentTraceReader(Protocol):
+    def read(self, job_id: str) -> List[Dict[str, Any]]:
+        ...
+
+
+def _utc_now() -> datetime:
+    return datetime.now(timezone.utc)
+
+
+def _timestamp(value: Any, *, fallback: Optional[datetime] = None) -> str:
+    if isinstance(value, datetime):
+        parsed = value
+    elif isinstance(value, str):
+        try:
+            parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+        except ValueError:
+            parsed = fallback or _utc_now()
+    else:
+        parsed = fallback or _utc_now()
+    if parsed.tzinfo is None:
+        parsed = parsed.replace(tzinfo=timezone.utc)
+    return parsed.astimezone(timezone.utc).isoformat()
+
+
+def sanitize_trace_summary(value: Any) -> Optional[str]:
+    """Keep the server's operational summary while removing common secret/PII forms."""
+
+    if not isinstance(value, str):
+        return None
+    text = " ".join(value.replace("\x00", " ").split())
+    if not text:
+        return None
+    text = _BEARER_RE.sub("Bearer [REDACTED]", text)
+    text = _SENSITIVE_ASSIGNMENT_RE.sub(lambda match: f"{match.group(1)}=[REDACTED]", text)
+    text = _URL_RE.sub("[URL]", text)
+    text = _PATH_RE.sub("[PATH]", text)
+    text = _EMAIL_RE.sub("[EMAIL]", text)
+    text = _LONG_NUMBER_RE.sub("[NUMBER]", text)
+    text = _XML_FRAGMENT_RE.sub("[XML]", text)
+    return text[:MAX_TRACE_SUMMARY_CHARS]
+
+
+def trace_run_id(event: OpenAgentEvent) -> Optional[str]:
+    """Return the run id only from the documented trace run-start event."""
+
+    payload = event.data
+    if event.event != "trace" or not isinstance(payload, Mapping):
+        return None
+    if payload.get("event") != "run.started":
+        return None
+    run_id = payload.get("run_id")
+    if not isinstance(run_id, str) or OPAQUE_ID_RE.fullmatch(run_id) is None:
+        return None
+    return run_id
+
+
+def project_trace_event(
+    event: OpenAgentEvent,
+    *,
+    attempt_no: int,
+    sequence: int,
+    now: Optional[datetime] = None,
+) -> Optional[Dict[str, Any]]:
+    """Project one raw SSE event into the strict local diagnostic contract."""
+
+    fallback = now or _utc_now()
+    if event.event == "end":
+        return _record(
+            sequence=sequence,
+            timestamp=_timestamp(None, fallback=fallback),
+            level="info",
+            code="AGENT_TRACE_STREAM_ENDED",
+            title="SuperAgent trace 流已结束",
+            message="SuperAgent 已关闭本次运行的 trace 连接。",
+            details={"attempt_no": attempt_no, "trace_event": "end"},
+        )
+    if event.event != "trace" or not isinstance(event.data, Mapping):
+        return None
+
+    payload = event.data
+    name = payload.get("event")
+    if not isinstance(name, str) or not name:
+        return None
+    if name in {"message.delta", "message.final"}:
+        # Assistant text can contain result payloads, grants, object routes or PII.
+        return None
+
+    nested = payload.get("data")
+    summary = sanitize_trace_summary(
+        nested.get("summary") if isinstance(nested, Mapping) else None
+    )
+    run_id = payload.get("run_id")
+    if not isinstance(run_id, str) or OPAQUE_ID_RE.fullmatch(run_id) is None:
+        run_id = None
+    status = payload.get("status")
+    if not isinstance(status, str) or not status or len(status) > 32:
+        status = None
+    details: Dict[str, Any] = {
+        "attempt_no": attempt_no,
+        "trace_event": name,
+        "remote_run_id": run_id,
+        "remote_status": status,
+    }
+    timestamp = _timestamp(payload.get("ts"), fallback=fallback)
+
+    if name == "run.started":
+        return _record(
+            sequence=sequence,
+            timestamp=timestamp,
+            level="info",
+            code="AGENT_RUN_STARTED",
+            title="SuperAgent 运行已启动",
+            message="已建立包含任务 trace 的 SuperAgent 运行流。",
+            details=details,
+        )
+    if name == "task.updated":
+        if summary is None:
+            return None
+        return _record(
+            sequence=sequence,
+            timestamp=timestamp,
+            level="info",
+            code="AGENT_TASK_UPDATED",
+            title="Agent 任务状态更新",
+            message=summary,
+            details=details,
+        )
+    if name == "step.updated":
+        if summary is None:
+            return None
+        return _record(
+            sequence=sequence,
+            timestamp=timestamp,
+            level="info",
+            code="AGENT_STEP_UPDATED",
+            title="Agent 执行步骤更新",
+            message=summary,
+            details=details,
+        )
+    if name == "run.completed":
+        succeeded = status in {"success", "completed", "succeeded"}
+        return _record(
+            sequence=sequence,
+            timestamp=timestamp,
+            level="success" if succeeded else "warning",
+            code="AGENT_RUN_COMPLETED",
+            title="SuperAgent 运行已结束",
+            message=summary or "SuperAgent 已报告运行终态;业务成功仍以 ARR 数据库提交为准。",
+            details=details,
+        )
+    if name in {"run.failed", "error"}:
+        return _record(
+            sequence=sequence,
+            timestamp=timestamp,
+            level="error",
+            code="AGENT_RUN_FAILED",
+            title="SuperAgent 运行失败",
+            message=summary or "SuperAgent trace 报告运行失败。",
+            details=details,
+        )
+    if name in {"run.cancelled", "run.canceled"}:
+        return _record(
+            sequence=sequence,
+            timestamp=timestamp,
+            level="warning",
+            code="AGENT_RUN_CANCELLED",
+            title="SuperAgent 运行已取消",
+            message=summary or "SuperAgent trace 报告运行已取消。",
+            details=details,
+        )
+    if summary is None:
+        return None
+    return _record(
+        sequence=sequence,
+        timestamp=timestamp,
+        level="info",
+        code="AGENT_TRACE_EVENT",
+        title="Agent trace 事件",
+        message=summary,
+        details=details,
+    )
+
+
+def capture_failure_record(
+    *,
+    attempt_no: int,
+    sequence: int,
+    error: BaseException,
+    now: Optional[datetime] = None,
+) -> Dict[str, Any]:
+    """Create a safe local event without serializing the exception message."""
+
+    error_name = type(error).__name__
+    if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,95}", error_name):
+        error_name = "Exception"
+    return _record(
+        sequence=sequence,
+        timestamp=_timestamp(now or _utc_now()),
+        level="warning",
+        code="AGENT_TRACE_CAPTURE_FAILED",
+        title="SuperAgent trace 采集中断",
+        message="远端运行可能仍在继续;当前日志连接未能完整消费。",
+        details={
+            "attempt_no": attempt_no,
+            "trace_event": "capture.failed",
+            "capture_error": error_name,
+        },
+    )
+
+
+def _record(
+    *,
+    sequence: int,
+    timestamp: str,
+    level: str,
+    code: str,
+    title: str,
+    message: str,
+    details: Optional[Mapping[str, Any]] = None,
+) -> Dict[str, Any]:
+    value: Dict[str, Any] = {
+        "version": TRACE_RECORD_VERSION,
+        "id": f"agent.{sequence:06d}.{code.lower()}",
+        "timestamp": timestamp,
+        "stage": "agent",
+        "level": level,
+        "code": code,
+        "title": title,
+        "message": message,
+    }
+    safe_details = _validated_details(details or {})
+    if safe_details:
+        value["details"] = safe_details
+    return value
+
+
+def _validated_details(value: Mapping[str, Any]) -> Dict[str, Any]:
+    output: Dict[str, Any] = {}
+    for key, item in value.items():
+        if key not in _SAFE_DETAIL_KEYS or item is None or item == "":
+            continue
+        if key in {"attempt_no", "invalid_records"}:
+            if isinstance(item, int) and not isinstance(item, bool) and item >= 0:
+                output[key] = item
+        elif key == "remote_run_id":
+            if isinstance(item, str) and OPAQUE_ID_RE.fullmatch(item) is not None:
+                output[key] = item
+        elif key == "capture_error":
+            if isinstance(item, str) and re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,95}", item):
+                output[key] = item
+        elif isinstance(item, str) and re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", item):
+            output[key] = item
+    return output
+
+
+def validate_trace_record(record: Mapping[str, Any]) -> Dict[str, Any]:
+    required = {
+        "version",
+        "id",
+        "timestamp",
+        "stage",
+        "level",
+        "code",
+        "title",
+        "message",
+    }
+    optional = {"details"}
+    if not isinstance(record, Mapping) or not required <= set(record) <= required | optional:
+        raise AgentTraceStoreError("trace record field set is invalid")
+    if record.get("version") != TRACE_RECORD_VERSION or record.get("stage") != "agent":
+        raise AgentTraceStoreError("trace record version or stage is invalid")
+    if record.get("level") not in _LEVELS:
+        raise AgentTraceStoreError("trace record level is invalid")
+    code = record.get("code")
+    if not isinstance(code, str) or _CODE_RE.fullmatch(code) is None:
+        raise AgentTraceStoreError("trace record code is invalid")
+    timestamp = _bounded_text(record.get("timestamp"), 64, "timestamp")
+    try:
+        parsed_timestamp = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
+    except ValueError as error:
+        raise AgentTraceStoreError("trace record timestamp is invalid") from error
+    if parsed_timestamp.tzinfo is None:
+        raise AgentTraceStoreError("trace record timestamp is invalid")
+    title = sanitize_trace_summary(_bounded_text(record.get("title"), 160, "title"))
+    message = sanitize_trace_summary(
+        _bounded_text(record.get("message"), MAX_TRACE_SUMMARY_CHARS, "message")
+    )
+    if title is None or message is None:
+        raise AgentTraceStoreError("trace record text is invalid")
+    output: Dict[str, Any] = {
+        "version": TRACE_RECORD_VERSION,
+        "id": _bounded_text(record.get("id"), 180, "id"),
+        "timestamp": parsed_timestamp.astimezone(timezone.utc).isoformat(),
+        "stage": "agent",
+        "level": str(record["level"]),
+        "code": code,
+        "title": title,
+        "message": message,
+    }
+    details = record.get("details")
+    if details is not None:
+        if not isinstance(details, Mapping):
+            raise AgentTraceStoreError("trace record details are invalid")
+        safe_details = _validated_details(details)
+        if safe_details:
+            output["details"] = safe_details
+    return output
+
+
+def _bounded_text(value: Any, maximum: int, label: str) -> str:
+    if not isinstance(value, str) or not value or len(value) > maximum:
+        raise AgentTraceStoreError(f"trace record {label} is invalid")
+    return value
+
+
+class JsonlAgentTraceStore:
+    """Append-only local trace store; raw SSE payloads are never written."""
+
+    def __init__(self, root: Union[str, Path]) -> None:
+        self.root = Path(root)
+        self._lock = threading.RLock()
+
+    def append(self, job_id: str, record: Mapping[str, Any]) -> None:
+        path = self._path(job_id)
+        value = validate_trace_record(record)
+        line = (
+            json.dumps(
+                value,
+                ensure_ascii=False,
+                allow_nan=False,
+                sort_keys=True,
+                separators=(",", ":"),
+            )
+            + "\n"
+        ).encode("utf-8")
+        try:
+            with self._lock:
+                if self.root.is_symlink() or (
+                    self.root.exists() and not self.root.is_dir()
+                ):
+                    raise AgentTraceStoreError("agent trace root is invalid")
+                self.root.mkdir(mode=0o700, parents=True, exist_ok=True)
+                flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND
+                if hasattr(os, "O_NOFOLLOW"):
+                    flags |= os.O_NOFOLLOW
+                descriptor = os.open(path, flags, 0o600)
+                try:
+                    remaining = memoryview(line)
+                    while remaining:
+                        written = os.write(descriptor, remaining)
+                        if written <= 0:
+                            raise OSError("trace append made no progress")
+                        remaining = remaining[written:]
+                finally:
+                    os.close(descriptor)
+        except (OSError, ValueError) as error:
+            raise AgentTraceStoreError("cannot append agent trace record") from error
+
+    def read(self, job_id: str) -> List[Dict[str, Any]]:
+        path = self._path(job_id)
+        try:
+            with self._lock:
+                if self.root.is_symlink():
+                    raise AgentTraceStoreError("agent trace root is invalid")
+                if not path.exists():
+                    return []
+                if path.is_symlink() or not path.is_file():
+                    raise AgentTraceStoreError("agent trace path is invalid")
+                metadata = path.stat()
+                if metadata.st_size > MAX_TRACE_FILE_BYTES:
+                    return [self._read_warning(metadata.st_mtime, 1, "file.too_large")]
+                modified_at = metadata.st_mtime
+                lines = path.read_text(encoding="utf-8").splitlines()
+        except AgentTraceStoreError:
+            raise
+        except (OSError, UnicodeError) as error:
+            raise AgentTraceStoreError("cannot read agent trace records") from error
+
+        records: List[Dict[str, Any]] = []
+        invalid = 0
+        for line in lines[:MAX_TRACE_EVENTS]:
+            try:
+                payload = json.loads(line)
+                records.append(validate_trace_record(payload))
+            except (ValueError, TypeError, AgentTraceStoreError):
+                invalid += 1
+        if len(lines) > MAX_TRACE_EVENTS:
+            invalid += len(lines) - MAX_TRACE_EVENTS
+        if invalid:
+            records.append(self._read_warning(modified_at, invalid, "records.invalid"))
+        return records
+
+    def _path(self, job_id: str) -> Path:
+        if not isinstance(job_id, str) or OPAQUE_ID_RE.fullmatch(job_id) is None:
+            raise AgentTraceStoreError("job id is invalid")
+        return self.root / f"{sha256(job_id.encode('utf-8')).hexdigest()}.jsonl"
+
+    @staticmethod
+    def _read_warning(modified_at: float, count: int, reason: str) -> Dict[str, Any]:
+        return _record(
+            sequence=MAX_TRACE_EVENTS + 1,
+            timestamp=datetime.fromtimestamp(modified_at, tz=timezone.utc).isoformat(),
+            level="warning",
+            code="AGENT_TRACE_RECORD_INVALID",
+            title="本地 Agent trace 记录不完整",
+            message="部分本地 trace 记录无法安全读取,已从任务日志中忽略。",
+            details={
+                "trace_event": reason,
+                "invalid_records": count,
+            },
+        )
diff --git a/arr_processing/local.py b/arr_processing/local.py
new file mode 100644
index 0000000..da6fa7c
--- /dev/null
+++ b/arr_processing/local.py
@@ -0,0 +1,155 @@
+"""ARR-owned execution of the frozen Opera daily processor."""
+
+from __future__ import annotations
+
+import subprocess
+from dataclasses import dataclass
+from datetime import date
+from pathlib import Path
+from typing import Dict, Mapping, Optional
+
+from arr_ingestion.contracts import IngestionError
+from arr_ingestion.validation import ProcessorPolicy, strict_json_file
+
+
+@dataclass(frozen=True)
+class LocalProcessingOutput:
+    status: str
+    business_date: Optional[date]
+    artifacts: Mapping[str, Path]
+    exit_code: int
+
+
+@dataclass(frozen=True)
+class LocalDailyProcessor:
+    """Run one isolated deterministic process and expose only its artifact paths."""
+
+    policy: ProcessorPolicy
+    timeout_seconds: int = 240
+
+    def __post_init__(self) -> None:
+        if (
+            not isinstance(self.timeout_seconds, int)
+            or isinstance(self.timeout_seconds, bool)
+            or not 10 <= self.timeout_seconds <= 900
+        ):
+            raise ValueError("local processor timeout is invalid")
+        if not (self.policy.skill_root / "scripts" / "process_daily.py").is_file():
+            raise ValueError("ARR daily processor is unavailable")
+
+    def run(self, source_xml: Path, output_dir: Path) -> LocalProcessingOutput:
+        if not source_xml.is_file():
+            raise IngestionError("SOURCE_NOT_FOUND", "registered source XML is unavailable")
+        output_dir.mkdir(parents=True, exist_ok=False, mode=0o700)
+        result_path = output_dir / "result.json"
+        structured_path = output_dir / "structured-result.json"
+        command = [
+            self.policy.python_binary,
+            str((self.policy.skill_root / "scripts" / "process_daily.py").resolve()),
+            "--xml",
+            str(source_xml.resolve()),
+            "--output-dir",
+            str(output_dir.resolve()),
+            "--result-json",
+            str(result_path.resolve()),
+            "--structured-result-json",
+            str(structured_path.resolve()),
+        ]
+        try:
+            completed = subprocess.run(
+                command,
+                stdin=subprocess.DEVNULL,
+                stdout=subprocess.PIPE,
+                stderr=subprocess.PIPE,
+                check=False,
+                timeout=self.timeout_seconds,
+            )
+        except subprocess.TimeoutExpired:
+            raise IngestionError(
+                "PROCESSOR_TIMEOUT",
+                "deterministic XML processing exceeded its time limit",
+            ) from None
+        except OSError:
+            raise IngestionError(
+                "PROCESSOR_UNAVAILABLE",
+                "deterministic XML processing could not start",
+            ) from None
+
+        result = strict_json_file(result_path, "processor result")
+        structured = strict_json_file(structured_path, "structured processor result")
+        status = result.get("status")
+        if (
+            status not in {"success", "failed"}
+            or structured.get("status") != status
+            or (completed.returncode == 0) != (status == "success")
+        ):
+            raise IngestionError(
+                "PROCESSOR_RESULT_INVALID",
+                "processor exit status and result artifacts do not agree",
+            )
+
+        raw_date = result.get("business_date")
+        business_date: Optional[date]
+        if raw_date is None:
+            business_date = None
+        elif isinstance(raw_date, str):
+            try:
+                business_date = date.fromisoformat(raw_date)
+            except ValueError:
+                raise IngestionError(
+                    "PROCESSOR_RESULT_INVALID", "processor business date is invalid"
+                ) from None
+        else:
+            raise IngestionError(
+                "PROCESSOR_RESULT_INVALID", "processor business date is invalid"
+            )
+
+        outputs = result.get("outputs")
+        if not isinstance(outputs, dict):
+            raise IngestionError(
+                "PROCESSOR_RESULT_INVALID", "processor output manifest is invalid"
+            )
+        artifacts: Dict[str, Path] = {
+            "result_json": result_path,
+            "structured_result_json": structured_path,
+        }
+        if status == "success":
+            artifacts["daily_report"] = self._output_path(
+                output_dir, outputs.get("daily_report"), ".xlsx"
+            )
+            if outputs.get("exception_report") is not None or business_date is None:
+                raise IngestionError(
+                    "PROCESSOR_RESULT_INVALID", "successful processor output is incomplete"
+                )
+        else:
+            artifacts["exception_report"] = self._output_path(
+                output_dir, outputs.get("exception_report"), ".xlsx"
+            )
+            if outputs.get("daily_report") is not None:
+                raise IngestionError(
+                    "PROCESSOR_RESULT_INVALID", "failed processor output is inconsistent"
+                )
+        return LocalProcessingOutput(
+            status=str(status),
+            business_date=business_date,
+            artifacts=artifacts,
+            exit_code=completed.returncode,
+        )
+
+    @staticmethod
+    def _output_path(output_dir: Path, value: object, suffix: str) -> Path:
+        if (
+            not isinstance(value, str)
+            or not value
+            or Path(value).name != value
+            or not value.lower().endswith(suffix)
+        ):
+            raise IngestionError(
+                "PROCESSOR_RESULT_INVALID", "processor output filename is invalid"
+            )
+        candidate = (output_dir / value).resolve()
+        if candidate.parent != output_dir.resolve() or not candidate.is_file():
+            raise IngestionError(
+                "PROCESSOR_RESULT_INVALID", "processor output artifact is unavailable"
+            )
+        return candidate
diff --git a/arr_processing/open_agent.py b/arr_processing/open_agent.py
index caa6317..b428116 100644
--- a/arr_processing/open_agent.py
+++ b/arr_processing/open_agent.py
@@ -3,7 +3,9 @@
 from __future__ import annotations
 
 import hashlib
-from typing import Any, Callable, Mapping, Optional
+import threading
+import time
+from typing import Any, Callable, Iterator, Mapping, NoReturn, Optional, Set
 
 from agent_integration.client import (
     OpenAgentAPIError,
@@ -11,6 +13,13 @@ from agent_integration.client import (
     OpenAgentTransportError,
 )
 from agent_integration.service import AgentResponseError, OpenAgentService
+from arr_processing.agent_trace import (
+    AgentTraceSink,
+    AgentTraceStoreError,
+    capture_failure_record,
+    project_trace_event,
+    trace_run_id,
+)
 from arr_processing.contracts import PROCESSING_REQUEST_VERSION, ProcessingRequest
 from arr_processing.errors import ProcessingTransportError
 from arr_processing.runner import RemoteRunSnapshot
@@ -24,34 +33,79 @@ class OpenAgentProcessingTransport:
         service: OpenAgentService,
         *,
         message_builder: Optional[Callable[[ProcessingRequest], str]] = None,
+        trace_store: Optional[AgentTraceSink] = None,
     ) -> None:
         self._service = service
         self._message_builder = message_builder or (lambda request: request.message())
+        self._trace_store = trace_store
+        self._trace_threads: Set[threading.Thread] = set()
+        self._trace_threads_lock = threading.Lock()
+        self._closed = False
 
     def submit(self, request: ProcessingRequest, idempotency_key: str) -> RemoteRunSnapshot:
         conversation_id = self.conversation_id(request.job_id)
         try:
+            message = self._message_builder(request)
+            metadata = {
+                "contract_version": PROCESSING_REQUEST_VERSION,
+                "processing_kind": "opera_daily",
+            }
+            if self._trace_store is not None:
+                try:
+                    return self._submit_traced(
+                        request,
+                        conversation_id=conversation_id,
+                        message=message,
+                        idempotency_key=idempotency_key,
+                        metadata=metadata,
+                    )
+                except (
+                    OpenAgentAPIError,
+                    OpenAgentTransportError,
+                    OpenAgentProtocolError,
+                    AgentResponseError,
+                ) as error:
+                    self._raise_mapped(error, stream_submit=True)
             response = self._service.send_message(
                 conversation_id,
-                self._message_builder(request),
+                message,
                 message_id=idempotency_key,
                 external_subject_id=conversation_id,
-                metadata={
-                    "contract_version": PROCESSING_REQUEST_VERSION,
-                    "processing_kind": "opera_daily",
-                },
+                metadata=metadata,
             )
-        except (OpenAgentAPIError, OpenAgentTransportError, OpenAgentProtocolError, AgentResponseError) as error:
+        except (
+            OpenAgentAPIError,
+            OpenAgentTransportError,
+            OpenAgentProtocolError,
+            AgentResponseError,
+        ) as error:
             self._raise_mapped(error)
         return self._snapshot(response)
 
+    def close(self, timeout_seconds: float = 2.0) -> None:
+        """Best-effort drain before the shared HTTP client is closed at shutdown."""
+
+        if timeout_seconds < 0:
+            raise ValueError("trace shutdown timeout must not be negative")
+        with self._trace_threads_lock:
+            self._closed = True
+            threads = list(self._trace_threads)
+        deadline = time.monotonic() + timeout_seconds
+        for thread in threads:
+            thread.join(max(0.0, deadline - time.monotonic()))
+
     def get(self, request: ProcessingRequest, remote_run_id: str) -> RemoteRunSnapshot:
         try:
             response = self._service.get_run(
                 self.conversation_id(request.job_id),
                 remote_run_id,
             )
-        except (OpenAgentAPIError, OpenAgentTransportError, OpenAgentProtocolError, AgentResponseError) as error:
+        except (
+            OpenAgentAPIError,
+            OpenAgentTransportError,
+            OpenAgentProtocolError,
+            AgentResponseError,
+        ) as error:
             self._raise_mapped(error)
         return self._snapshot(response, expected_run_id=remote_run_id)
 
@@ -61,7 +115,12 @@ class OpenAgentProcessingTransport:
                 self.conversation_id(request.job_id),
                 remote_run_id,
             )
-        except (OpenAgentAPIError, OpenAgentTransportError, OpenAgentProtocolError, AgentResponseError) as error:
+        except (
+            OpenAgentAPIError,
+            OpenAgentTransportError,
+            OpenAgentProtocolError,
+            AgentResponseError,
+        ) as error:
             self._raise_mapped(error)
         return self._snapshot(response, expected_run_id=remote_run_id)
 
@@ -70,6 +129,122 @@ class OpenAgentProcessingTransport:
         digest = hashlib.sha256(job_id.encode("utf-8")).hexdigest()
         return "arrproc_" + digest[:48]
 
+    def _submit_traced(
+        self,
+        request: ProcessingRequest,
+        *,
+        conversation_id: str,
+        message: str,
+        idempotency_key: str,
+        metadata: Mapping[str, Any],
+    ) -> RemoteRunSnapshot:
+        events = iter(
+            self._service.stream_message(
+                conversation_id,
+                message,
+                message_id=idempotency_key,
+                external_subject_id=conversation_id,
+                metadata=dict(metadata),
+                include_trace=True,
+            )
+        )
+        sequence = 0
+        for event in events:
+            sequence += 1
+            self._capture_trace(request, event, sequence)
+            remote_run_id = trace_run_id(event)
+            if remote_run_id is None:
+                continue
+            self._start_trace_drain(request, events, sequence)
+            return RemoteRunSnapshot(remote_run_id, "running")
+        raise AgentResponseError("trace stream ended before run.started")
+
+    def _start_trace_drain(
+        self,
+        request: ProcessingRequest,
+        events: Iterator[Any],
+        sequence: int,
+    ) -> None:
+        thread = threading.Thread(
+            target=self._drain_trace,
+            args=(request, events, sequence),
+            name=f"arr-agent-trace-{request.attempt_no}",
+            daemon=True,
+        )
+        with self._trace_threads_lock:
+            if self._closed:
+                try:
+                    close = getattr(events, "close", None)
+                    if callable(close):
+                        close()
+                finally:
+                    self._append_trace(
+                        request.job_id,
+                        capture_failure_record(
+                            attempt_no=request.attempt_no,
+                            sequence=sequence + 1,
+                            error=RuntimeError("transport closed"),
+                        ),
+                    )
+                return
+            self._trace_threads.add(thread)
+        try:
+            thread.start()
+        except RuntimeError as error:
+            with self._trace_threads_lock:
+                self._trace_threads.discard(thread)
+            close = getattr(events, "close", None)
+            if callable(close):
+                close()
+            self._append_trace(
+                request.job_id,
+                capture_failure_record(
+                    attempt_no=request.attempt_no,
+                    sequence=sequence + 1,
+                    error=error,
+                ),
+            )
+
+    def _drain_trace(
+        self,
+        request: ProcessingRequest,
+        events: Iterator[Any],
+        sequence: int,
+    ) -> None:
+        try:
+            for event in events:
+                sequence += 1
+                self._capture_trace(request, event, sequence)
+        except Exception as error:  # best-effort diagnostics must not affect the run
+            self._append_trace(
+                request.job_id,
+                capture_failure_record(
+                    attempt_no=request.attempt_no,
+                    sequence=sequence + 1,
+                    error=error,
+                ),
+            )
+        finally:
+            with self._trace_threads_lock:
+                self._trace_threads.discard(threading.current_thread())
+
+    def _capture_trace(self, request: ProcessingRequest, event: Any, sequence: int) -> None:
+        projected = project_trace_event(
+            event,
+            attempt_no=request.attempt_no,
+            sequence=sequence,
+        )
+        if projected is not None:
+            self._append_trace(request.job_id, projected)
+
+    def _append_trace(self, job_id: str, record: Mapping[str, Any]) -> None:
+        if self._trace_store is None:
+            return
+        try:
+            self._trace_store.append(job_id, record)
+        except (AgentTraceStoreError, OSError, ValueError, TypeError):
+            pass
+
     @staticmethod
     def _snapshot(
         response: Mapping[str, Any],
@@ -92,7 +267,11 @@ class OpenAgentProcessingTransport:
         return RemoteRunSnapshot(run_id, status.lower(), final_content)
 
     @staticmethod
-    def _raise_mapped(error: BaseException) -> None:
+    def _raise_mapped(
+        error: BaseException,
+        *,
+        stream_submit: bool = False,
+    ) -> NoReturn:
         if isinstance(error, OpenAgentAPIError):
             if error.active_run_conflict:
                 raise ProcessingTransportError(
@@ -100,6 +279,11 @@ class OpenAgentProcessingTransport:
                     retryable=True,
                     active_run_conflict=True,
                 ) from None
+            if stream_submit and (error.status_code == 408 or error.status_code >= 500):
+                raise ProcessingTransportError(
+                    "PROCESSING_REMOTE_SUBMISSION_AMBIGUOUS",
+                    retryable=False,
+                ) from None
             if error.retryable:
                 code = (
                     "PROCESSING_REMOTE_RATE_LIMITED"
@@ -109,9 +293,19 @@ class OpenAgentProcessingTransport:
                 raise ProcessingTransportError(code, retryable=True) from None
             raise ProcessingTransportError("PROCESSING_REMOTE_REJECTED", retryable=False) from None
         if isinstance(error, OpenAgentTransportError):
+            if stream_submit:
+                raise ProcessingTransportError(
+                    "PROCESSING_REMOTE_SUBMISSION_AMBIGUOUS",
+                    retryable=False,
+                ) from None
             raise ProcessingTransportError(
                 "PROCESSING_REMOTE_UNAVAILABLE", retryable=True
             ) from None
+        if stream_submit:
+            raise ProcessingTransportError(
+                "PROCESSING_REMOTE_SUBMISSION_AMBIGUOUS",
+                retryable=False,
+            ) from None
         raise ProcessingTransportError(
             "PROCESSING_REMOTE_PROTOCOL_INVALID", retryable=False
         ) from None
diff --git a/arr_processing/policy.py b/arr_processing/policy.py
new file mode 100644
index 0000000..e122017
--- /dev/null
+++ b/arr_processing/policy.py
@@ -0,0 +1,35 @@
+"""Neutral discovery of the frozen ARR deterministic processor identity."""
+
+from __future__ import annotations
+
+import importlib.util
+import sys
+from pathlib import Path
+
+from arr_ingestion.validation import ProcessorPolicy
+
+
+def load_processor_policy(project_root: Path) -> ProcessorPolicy:
+    skill_root = (project_root / "arr-opera-daily-ingest").resolve()
+    script = skill_root / "scripts" / "process_daily.py"
+    module_name = "_arr_runtime_process_daily"
+    specification = importlib.util.spec_from_file_location(module_name, script)
+    if specification is None or specification.loader is None:
+        raise ValueError("ARR daily processor identity is unavailable")
+    module = importlib.util.module_from_spec(specification)
+    prior = sys.modules.get(module_name)
+    sys.modules[module_name] = module
+    try:
+        specification.loader.exec_module(module)
+        processor_version = str(module.PROCESSOR_VERSION)
+        rule_set_sha256 = str(module.rule_set_sha256())
+    finally:
+        if prior is None:
+            sys.modules.pop(module_name, None)
+        else:
+            sys.modules[module_name] = prior
+    return ProcessorPolicy(
+        processor_version=processor_version,
+        rule_set_sha256=rule_set_sha256,
+        skill_root=skill_root,
+    )
diff --git a/arr_processing/postgres.py b/arr_processing/postgres.py
index 073f864..28aba81 100644
--- a/arr_processing/postgres.py
+++ b/arr_processing/postgres.py
@@ -23,6 +23,7 @@ _SAFE_START_FAILURE_CODES = frozenset(
         "PROCESSING_REMOTE_PROTOCOL_INVALID",
         "PROCESSING_REMOTE_RATE_LIMITED",
         "PROCESSING_REMOTE_REJECTED",
+        "PROCESSING_REMOTE_SUBMISSION_AMBIGUOUS",
         "PROCESSING_REMOTE_UNAVAILABLE",
     }
 )
diff --git a/arr_processing/source_message.py b/arr_processing/source_message.py
index 2e09233..95bbc36 100644
--- a/arr_processing/source_message.py
+++ b/arr_processing/source_message.py
@@ -4,7 +4,7 @@ from __future__ import annotations
 
 from dataclasses import dataclass
 from typing import Any, Dict, Protocol
-from urllib.parse import urlparse
+from urllib.parse import quote, urlparse
 
 from arr_ingestion.contracts import ArtifactRef, ROLE_CONTRACTS
 from arr_processing.contracts import ProcessingRequest, canonical_json_bytes
@@ -13,7 +13,7 @@ from arr_storage.aliyun_oss_v2 import AliyunOssConfig
 from arr_storage.contracts import valid_object_key
 
 
-PROGRAM_INPUT_VERSION = "arr-opera-daily-program-input-2"
+PROGRAM_INPUT_VERSION = "arr-opera-daily-program-input-3"
 
 
 class ProcessingSourceResolver(Protocol):
@@ -23,12 +23,12 @@ class ProcessingSourceResolver(Protocol):
 
 @dataclass(frozen=True)
 class OssProcessingMessageBuilder:
-    """Resolve the ARR-owned source object and expose only non-secret OSS routing.
+    """Resolve one ARR-owned source to its public-read HTTPS object URL.
 
-    ``fetch_oss_file`` owns the download and its credential provider.  The Agent
-    receives the same attachment-shaped descriptor used by the existing hotel
-    runtime: bucket, endpoint host, exact object key, hash and size.  AccessKey,
-    Secret, local paths and signed URLs never enter the message.
+    The Agent receives an attachment-shaped descriptor containing the exact URL
+    that ``fetch_oss_file`` must use, plus non-secret provenance and integrity
+    fields. AccessKey, Secret, local paths and signed URLs never enter the
+    message.
     """
 
     config: AliyunOssConfig
@@ -37,6 +37,7 @@ class OssProcessingMessageBuilder:
     def message(self, request: ProcessingRequest) -> str:
         source = self.sources.source_for_attempt(request.job_id, request.attempt_no)
         self._validate_source(request, source)
+        endpoint_host = self._endpoint_host()
         if request.submission_grant is None:
             raise ProcessingError(
                 "PROCESSING_SUBMISSION_GRANT_MISSING",
@@ -60,8 +61,12 @@ class OssProcessingMessageBuilder:
                     "source_ref": {"source": "oss_attachments", "index": 0},
                     "oss": {
                         "bucket": self.config.bucket,
-                        "endpoint": self._endpoint_host(),
+                        "endpoint": endpoint_host,
                         "object_key": source.object_key,
+                        "url": self._public_object_url(
+                            endpoint_host,
+                            source.object_key,
+                        ),
                     },
                 }
             ],
@@ -101,12 +106,22 @@ class OssProcessingMessageBuilder:
             )
 
     def _endpoint_host(self) -> str:
+        expected_host = f"oss-{self.config.region}.aliyuncs.com"
         if self.config.endpoint:
             parsed = urlparse(self.config.endpoint)
-            if parsed.scheme != "https" or not parsed.hostname:
+            if (
+                parsed.scheme != "https"
+                or parsed.hostname != expected_host
+                or parsed.username is not None
+                or parsed.password is not None
+                or parsed.port is not None
+            ):
                 raise ProcessingError(
                     "PROCESSING_SOURCE_INVALID",
-                    "OSS endpoint is unavailable",
+                    "public OSS endpoint is unavailable",
                 )
-            return parsed.netloc
-        return f"oss-{self.config.region}.aliyuncs.com"
+        return expected_host
+
+    def _public_object_url(self, endpoint_host: str, object_key: str) -> str:
+        encoded_key = quote(object_key, safe="/")
+        return f"https://{self.config.bucket}.{endpoint_host}/{encoded_key}"
diff --git a/arr_storage/README.md b/arr_storage/README.md
index ef336ef..7fdb4f2 100644
--- a/arr_storage/README.md
+++ b/arr_storage/README.md
@@ -1,76 +1,20 @@
-# ARR private object exchange
+# ARR guarded object storage
 
-`arr_storage` is the ARR-owned binary exchange boundary for the Opera XML flow. It is infrastructure only: it does not parse XML, invoke an Agent, generate reports, or write business facts.
+`ManagedObjectStore` owns an immutable staged-to-committed workflow for source and generated artifacts. Canonical keys
+contain only opaque job/attempt identity and role-controlled filenames; user filenames never enter object keys.
 
-## Frozen object contract
+Every object carries job, attempt, state, role, SHA-256, exact byte size and MIME metadata. Upload snapshots and hashes
+the local file, conditionally creates a staged object, conditionally copies to the committed key, verifies committed
+bytes, and deletes only the exact staged object.
 
-Every object is private and immutable. The only accepted key shape is:
+ARR2 OSS requirements:
 
-```text
-{prefix}/jobs/{opaque_job_id}/attempts/{0001..9999}/{staged|committed}/{role}/{canonical_filename}
-```
+- configured region matches the bucket and server-side encryption is enabled;
+- versioning is neither Enabled nor Suspended;
+- bucket ACL is `private` or `public-read`, never `public-read-write`;
+- every ARR-managed object is explicitly written with object ACL `private`;
+- credentials come from the SDK environment/RAM/STS chain and ambient desktop proxies are disabled.
 
-The default prefix is `arr`. Object-key filenames are controlled (`source.xml`, `daily-report.xlsx`, `result.json`, `structured-result.json`, `exception-report.xlsx`), so an uploaded user filename cannot leak guest or booking data through an OSS key. The delivery filename remains separate and is validated before it can enter `ArtifactRef`.
-
-Objects carry Schema 1.0 metadata for job, attempt, role, state, SHA-256, exact byte size, and MIME type. ETag is stored only as provider evidence; it is never treated as a content hash. A cloud adapter should preserve a provider version ID when bucket versioning exposes one.
-
-Upload is two-step:
-
-1. copy the input into a private snapshot while computing SHA-256 and enforcing the role limit;
-2. create `staged` with an if-absent condition, server-side copy it to a fresh `committed` key, download/hash-check the committed object, then delete only the exact staged object.
-
-No code path overwrites a committed key. An idempotent retry succeeds only when the existing object has the same metadata and bytes. Only committed keys implement `ArtifactStore.materialize()` and may enter a `DeliveryEnvelope`.
-
-## Cloud adapter boundary
-
-`CloudObjectBackend` accepts a narrow `CloudClientPort`. `AliyunOssV2Client` implements it with the Aliyun OSS Python SDK V2: conditional upload, head, streamed read, conditional server-side copy, exact-key delete, normalized custom metadata/content type and safe provider error classes. Startup queries bucket info and fails closed unless the region matches, the bucket does not allow anonymous writes, server-side encryption is configured and versioning is off. A `public-read` bucket is supported by explicit deployment decision; every ARR put/copy sets object ACL `private`, which prevents new processing outputs from inheriting public read access.
-
-Install `requirements-oss.txt`, then inject `ARR_OSS_REGION`, `ARR_OSS_BUCKET` and optional HTTPS `ARR_OSS_ENDPOINT`. Credentials use the SDK environment credential provider (RAM/STS variables); no access key is accepted from an Agent message or source file. The adapter can be present while live readiness remains false when deployment values are absent.
-
-The SDK transport uses a dedicated HTTP session with ambient desktop/system proxies disabled, so OSS credentials are not silently routed through an unrelated proxy. A deployment that requires an outbound proxy must add and review an explicit transport configuration rather than relying on inherited OS settings.
-
-Provider requirements:
-
-- bucket ACL is `private` or the explicitly approved `public-read` mode, TLS is required, and server-side encryption is enabled; `public-read-write` is always rejected;
-- ARR and the runtime fetch provider use separate least-privilege identities;
-- ARR can put/head/get/copy exact `arr/jobs/` keys; the runtime can only read the resolved committed source object;
-- credentials come from platform secret/instance-role facilities, never prompts, object metadata, URLs, source files, `.env.example`, or logs;
-- bucket versioning status must be `Off`; the adapter fails closed for `Enabled` or `Suspended`, because the required forbid-overwrite condition is not honored in those modes;
-- conditional create must be real provider-side `If-None-Match`/forbid-overwrite behavior, not a head-then-overwrite sequence.
-
-## Agent source fetch
-
-ARR uploads the XML itself and records the committed object before Agent dispatch. The program message contains one attachment-shaped descriptor with bucket, endpoint, exact object key, hash and byte size. Those values are routing and integrity metadata, not download credentials. The installed `fetch_oss_file` provider owns its OSS credential and must be restricted to read-only access to the ARR source prefix.
-
-The Agent must not use an OSS SDK, URL or user-supplied key, and it must hash/size-check the materialized file before invoking the Skill.
-
-## Optional short-lived fetch authorization
-
-`InMemoryReadGrantBroker` and `PostgresReadGrantBroker` remain available for a future runtime that supports opaque, job-bound, single-read grants. They are not required by the currently installed `fetch_oss_file` contract, which consumes the attachment-shaped OSS descriptor directly.
-
-## Lifecycle
-
-- incomplete `staged` objects: provider lifecycle deletion after 24 hours;
-- committed source XML and Agent outputs: no automatic deletion within their business month; retain according to the approved data-retention policy after that month;
-- generated monthly/channel downloads: governed by their report archive policy, not by the Agent exchange prefix;
-- short-lived grants: at most 300 seconds and one materialization;
-- logs/outbox: opaque IDs, state, SHA-256, byte size and safe error code only; never signed URLs, raw filenames, XML bytes, guest fields, or credentials.
-
-Local wiring:
-
-```python
-from pathlib import Path
-
-from arr_storage import FilesystemObjectBackend, ManagedObjectStore
-
-backend = FilesystemObjectBackend(Path("/private/arr-object-store"))
-store = ManagedObjectStore(backend)
-committed = store.upload_committed(
-    job_id="job-001",
-    attempt_no=1,
-    role="source_xml",
-    source=Path("/private/upload/input.xml"),
-    original_filename="source.xml",
-)
-source_ref = committed.to_artifact_ref()
-```
+The active flow materializes the committed source into a private temporary directory before processing. No public URL,
+Provider, fetch grant or external downloader is used. Legacy grant/output-exchange modules remain inactive compatibility
+code.
diff --git a/arr_storage/aliyun_oss_v2.py b/arr_storage/aliyun_oss_v2.py
index f2ac8d4..36d8ae3 100644
--- a/arr_storage/aliyun_oss_v2.py
+++ b/arr_storage/aliyun_oss_v2.py
@@ -191,7 +191,7 @@ class AliyunOssV2Client:
         return self._config.bucket
 
     def assert_immutable_writes_supported(self) -> None:
-        """Reject buckets that cannot safely hold private immutable ARR data."""
+        """Require encrypted, unversioned storage with no anonymous writes."""
 
         try:
             result = self._client.get_bucket_info(
@@ -206,10 +206,6 @@ class AliyunOssV2Client:
         if location not in {self._config.region, f"oss-{self._config.region}"}:
             raise CloudClientError("region_mismatch")
         acl = str(getattr(info, "acl", "") or "").strip().lower()
-        # A public-read bucket is an approved deployment choice for this
-        # integration. Every ARR object is still written with an explicit
-        # private object ACL, which overrides the bucket ACL. Never accept a
-        # bucket that grants anonymous writes.
         if acl not in {"private", "public-read"}:
             raise CloudClientError("public_access_incompatible")
         if getattr(info, "sse_rule", None) is None:
@@ -231,7 +227,7 @@ class AliyunOssV2Client:
                 self._sdk.PutObjectRequest(
                     bucket=self.bucket,
                     key=object_key,
-                    acl="private",
+                    acl=self._object_acl(object_key, metadata),
                     content_type=mime_type,
                     metadata=dict(metadata),
                     forbid_overwrite=True,
@@ -299,7 +295,7 @@ class AliyunOssV2Client:
                     key=destination_key,
                     source_bucket=self.bucket,
                     source_key=source_key,
-                    acl="private",
+                    acl=self._object_acl(destination_key, metadata),
                     metadata=dict(metadata),
                     metadata_directive="REPLACE",
                     content_type=metadata.get("arr-mime-type"),
@@ -337,6 +333,11 @@ class AliyunOssV2Client:
     def _optional_text(value: Any) -> Optional[str]:
         return value if isinstance(value, str) and value else None
 
+    @staticmethod
+    def _object_acl(object_key: str, metadata: Mapping[str, str]) -> str:
+        del object_key, metadata
+        return "private"
+
     @staticmethod
     def _validate_key(object_key: str) -> None:
         if not valid_object_key(object_key):
diff --git a/arr_storage/contracts.py b/arr_storage/contracts.py
index efd68df..576f587 100644
--- a/arr_storage/contracts.py
+++ b/arr_storage/contracts.py
@@ -20,6 +20,7 @@ OBJECT_METADATA_SCHEMA = "1.0"
 OBJECT_STATES = frozenset({"staged", "committed"})
 CANONICAL_OBJECT_FILENAMES = {
     "source_xml": "source.xml",
+    "booking_source": "booking-source.xlsx",
     "daily_report": "daily-report.xlsx",
     "result_json": "result.json",
     "structured_result_json": "structured-result.json",
diff --git a/arr_web/README.md b/arr_web/README.md
index 455a7d8..9b47a3a 100644
--- a/arr_web/README.md
+++ b/arr_web/README.md
@@ -1,100 +1,39 @@
-# ARR Web Portal
+# ARR 2.0 Web
 
-The portal exposes four surfaces:
+Web 层提供固定页面和审计友好的 API。生产 XML 写路径为:
 
-- 日报处理:XML upload, remote processing status, ARR validation and database result;
-- 月报处理:ordinary program generation from current database facts;
-- 渠道 BI:live read-only aggregation from current retained facts;
-- 渠道使用情况:company ten-day report jobs.
+`POST /api/jobs` → `ProgrammaticUploadCoordinator` → 固定处理器 → `DeliveryValidator` → PostgreSQL 原子提交。
 
-## Current database model
-
-`arr_web.repository.PostgresPortalRepository` reads migration 008 relations:
-
-- `ingestion.processing_runs` / `ingestion.artifacts` for daily history and artifacts;
-- `finance.daily_versions` / `finance.current_daily_versions` for validated dates;
-- `finance.v_active_daily_facts` and current channel metrics through `channel_analytics`.
-
-There are no database `report_versions`. The monthly-runs read endpoint exposes a source-ready monthly projection (dates, channels and row count) and does not claim an XLSX artifact. Generated monthly files live in the controlled publishing/OSS layer.
-
-## Local run
-
-```bash
-PYTHONPYCACHEPREFIX=/private/tmp/arr-web-pyc \
-.venv/bin/python -m arr_web.run \
-  --host 127.0.0.1 \
-  --port 8765 \
-  --db-config /path/to/private/booking-test-db.env \
-  --enable-processing \
-  --enable-agent-writeback \
-  --enable-monthly-generation \
-  --enable-company-reports \
-  --node-binary /absolute/path/to/node \
-  --artifact-tool-module /absolute/path/to/artifact_tool.mjs
-```
-
-- Desktop: `http://127.0.0.1:8765/`
-- H5: `http://127.0.0.1:8765/h5`
-- Health: `http://127.0.0.1:8765/api/health`
-
-The active test target is the controlled `:5432/booking_test`. The old `booking-test-lan-db.env` is retained only for temporary rollback checks and should not receive new writes.
-
-`--enable-processing` is also fail-closed. It reports `processing_ready=true`
-only when PostgreSQL, the guarded OSS uploader and the Open Agent client all
-initialize; the dispatched message contains one `fetch_oss_file` attachment
-descriptor and no OSS credential.
-
-`--enable-agent-writeback` is fail-closed. `GET /api/health` reports
-`agent_writeback_ready=true` only after the current processor policy, PostgreSQL
-state/repository, independent HMAC verifier, Aliyun OSS backend and controlled
-output reader all initialize successfully. Missing or incompatible deployment
-configuration keeps the callback route unavailable instead of accepting an
-unverified result.
-
-The local LaunchAgent wrapper optionally loads
-`/path/to/private/agent-writeback.env`. It refuses a symlink,
-non-regular file, foreign owner or permissions other than `0600`; no real
-secret belongs in this repository or `.env.example`.
-
-Local secrets use the macOS Keychain account `arr-web`. The launcher maps the
-OSS key ID/secret and the Agent Open API key from their dedicated service names
-into process environment variables; the callback HMAC remains an independent
-Keychain item. At present the launchd label and launcher/log paths still use
-the historical project location even though the process runs this repository;
-that deployment-path cleanup is separate from application readiness.
-
-## Security
-
-- Browsers never receive a PostgreSQL DSN or OSS credential.
-- Read transactions are repeatable-read/read-only and reject any database other than `booking_test`.
-- Writes use same-origin session/CSRF; machine result callbacks use signed result verification.
-- Downloads are resolved from server-owned artifact identity, then rechecked for path, type, size and SHA-256.
-- Production needs dedicated reader/writer roles and a provider-backed private OSS reader; the current test database owner and local fixture reader are not production configuration.
-
-## API
+主要接口:
 
+- `GET /login`、`POST /api/login`、`POST /api/logout`
 - `GET /api/session`
 - `GET /api/health`
-- `GET|POST /api/jobs`
-- `GET|POST /api/monthly-runs`
-- `GET /api/months`
-- `GET /api/analytics`
-- `GET /api/h5/months` (legacy H5 raw compatibility)
-- `GET /api/monthly/{month_key}/analytics` (analytics 1.2 raw compatibility)
-- `GET /api/channel-detail`
-- `GET|POST /api/company-reports/jobs`
-- `POST /api/integrations/super-agent/results`
+- `GET /healthz`:仅供容器读取的无详情 readiness 探针
+- `POST /api/jobs`
+- `GET /api/jobs`
+- `GET /api/jobs/{job_id}/trace`
+- `GET /api/download/daily`
+- `GET /api/monthly-runs?month=YYYY-MM`:读取已持久化的月报版本
+- `GET /api/download/monthly?report_id=...`:下载登记为 active/superseded 的月报并复核路径、大小和 SHA-256
+- `POST /api/company-reports/source`:上传并提取原始 Booking XLSX,先创建复核草稿
+- `GET /api/company-reports/source/draft`:分页读取当前复核草稿
+- `PATCH`/`DELETE /api/company-reports/source/draft/items/{id}`:人工保存房型/数量或删除记录
+- `DELETE /api/company-reports/source/draft/items`:在同一事务中批量删除最多 50 条所选记录
+- `DELETE /api/company-reports/source/draft`、`POST /api/company-reports/source/draft/activate`:放弃草稿或在零待人工时原子启用
+- BI 与公司报表接口
 
-The machine callback accepts the canonical signed body described in
-[`AGENT_WRITEBACK_CONTRACT.md`](../AGENT_WRITEBACK_CONTRACT.md). HTTP `200`
-means that the delivery is committed (or is an identical idempotent replay),
-not merely that the remote Agent Run reported `success`.
+ARR 2.0 不注册 `/api/integrations/super-agent/results`。访问旧回调地址返回 `404 ROUTE_NOT_FOUND`。
 
-## Verification
+除登录静态资源、`POST /api/login` 和 `/healthz` 外,页面、API、上传与下载均要求应用登录。Web 进程从 `ARR_WEB_USERNAME` / `ARR_WEB_PASSWORD` 读取 Finance 操作员凭据;缺失时启动失败。会话 cookie 为 `HttpOnly`、`SameSite=Strict`,HTTPS 部署必须启用 `--secure-cookies`。Caddy 仅终结 HTTPS,不再使用浏览器原生 Basic Auth。
 
-```bash
-PYTHONPYCACHEPREFIX=/private/tmp/arr-web-pyc \
-python3 -m unittest tests.test_arr_web_repository_schema tests.test_arr_web -v
-```
+`--enable-processing` 只有在数据库、OSS、固定处理器和独立验证器全部初始化成功时才使 `processing_ready=true`。上传处理是同步终态边界:HTTP 保持 `202` 兼容页面,但响应前已经完成验证/入库或记录失败。
 
-The full fake-transport end-to-end suite remains privacy-safe and uses synthetic identifiers only.
+日报历史和任务日志中的“文件”只显示浏览器上传的 XML basename。内部源工件继续规范化为 `source.xml`,不会暴露到这两个用户界面;迁移前的历史任务没有可还原的上传名,页面显示 `—`。
+
+月报主页面没有月份、截止日、手动生成或手动刷新控件。日报提交成功后,独立 `monthly_reports.worker` 消费 outbox 事件;它从数据库内纳入数据的最新 `ARRIVAL` 派生“更新至”。月报页可见时每四秒自动读取 `reporting.monthly_runs`,新版本发布后直接新增;页面隐藏或离开该页时停止轮询,恢复时立即读取。Web 内部 POST 生成端点只作为显式启用的受控恢复入口,不属于用户主流程。
+
+公司渠道明细的 Booking XLSX 使用“提取 → 人工核对 → 整表启用”边界。上传只写私有源工件和
+`booking.extraction_*` 草稿;自动/人工记录都能编辑或删除,待人工项目不会进入有效房量。只有草稿不存在
+待人工项目且至少保留一条确认记录时,后端才在一个事务中生成 accepted Booking 批次并切换
+`booking.current_source_batch`。打开草稿期间禁止创建新的公司渠道明细任务。
diff --git a/arr_web/agent_writeback_runtime.py b/arr_web/agent_writeback_runtime.py
index 2e95aaf..a16ed6f 100644
--- a/arr_web/agent_writeback_runtime.py
+++ b/arr_web/agent_writeback_runtime.py
@@ -2,9 +2,7 @@
 
 from __future__ import annotations
 
-import importlib.util
 import os
-import sys
 from dataclasses import dataclass
 from pathlib import Path
 from typing import Any, Callable, Optional
@@ -19,12 +17,13 @@ from arr_processing.postgres import PostgresProcessingState, ProcessingDatabaseC
 from arr_processing.registration import ProcessingOutputRegistrar, RemoteFilePort
 from arr_processing.remote_files import PrefixRemoteFilePort
 from arr_processing.runner import ProcessingRunner, RemoteRunSnapshot
+from arr_processing.policy import load_processor_policy
 from arr_storage.aliyun_oss_v2 import AliyunOssConfig, AliyunOssV2Client
 from arr_storage.contracts import ObjectKeyPolicy
 from arr_storage.exchange import OutputExchangeConfig
 from arr_storage.remote import CloudObjectBackend
 from arr_storage.store import ManagedObjectStore
-from arr_web.services import ProcessingAgentResultCoordinator
+from arr_web.legacy_agent_services import ProcessingAgentResultCoordinator
 
 
 class CallbackOnlyTransport:
@@ -60,32 +59,6 @@ class AgentWritebackRuntime:
             self.close_callback()
 
 
-def load_processor_policy(project_root: Path) -> ProcessorPolicy:
-    skill_root = (project_root / "arr-opera-daily-ingest").resolve()
-    script = skill_root / "scripts" / "process_daily.py"
-    module_name = "_arr_runtime_process_daily"
-    specification = importlib.util.spec_from_file_location(module_name, script)
-    if specification is None or specification.loader is None:
-        raise ValueError("ARR daily processor identity is unavailable")
-    module = importlib.util.module_from_spec(specification)
-    prior = sys.modules.get(module_name)
-    sys.modules[module_name] = module
-    try:
-        specification.loader.exec_module(module)
-        processor_version = str(module.PROCESSOR_VERSION)
-        rule_set_sha256 = str(module.rule_set_sha256())
-    finally:
-        if prior is None:
-            sys.modules.pop(module_name, None)
-        else:
-            sys.modules[module_name] = prior
-    return ProcessorPolicy(
-        processor_version=processor_version,
-        rule_set_sha256=rule_set_sha256,
-        skill_root=skill_root,
-    )
-
-
 def compose_agent_writeback(
     *,
     object_store: ManagedObjectStore,
diff --git a/arr_web/app.py b/arr_web/app.py
index 1089ff2..2699cca 100644
--- a/arr_web/app.py
+++ b/arr_web/app.py
@@ -16,12 +16,18 @@ from urllib.parse import parse_qs, urlsplit
 from urllib.parse import quote
 from zoneinfo import ZoneInfo
 
+from arr_web.auth import LoginAttemptLedger, LoginCredentials
+from arr_web.booking_uploads import (
+    BookingSourceCoordinator,
+    UnavailableBookingSourceCoordinator,
+)
 from arr_web.contracts import (
     MAX_UPLOAD_BYTES,
     PortalError,
     Response,
     failure,
     success,
+    validate_job_id,
     validate_month,
     validate_upload_filename,
     validate_xml_payload,
@@ -38,9 +44,7 @@ from arr_web.company_jobs import (
     UnavailableCompanyReportCoordinator,
 )
 from arr_web.services import (
-    AgentResultCoordinator,
     MonthlyCoordinator,
-    UnavailableAgentResultCoordinator,
     UnavailableMonthlyCoordinator,
     UnavailableUploadCoordinator,
     UploadCoordinator,
@@ -57,7 +61,38 @@ STATIC_ROUTES = {
     "/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
     "/assets/h5.css": ("h5.css", "text/css; charset=utf-8"),
     "/assets/h5.js": ("h5.js", "text/javascript; charset=utf-8"),
+    "/login": ("login.html", "text/html; charset=utf-8"),
+    "/login.html": ("login.html", "text/html; charset=utf-8"),
+    "/assets/login.css": ("login.css", "text/css; charset=utf-8"),
+    "/assets/login.js": ("login.js", "text/javascript; charset=utf-8"),
 }
+LOGIN_STATIC_ROUTES = {
+    "/login",
+    "/login.html",
+    "/assets/login.css",
+    "/assets/login.js",
+}
+LOGIN_DOCUMENT_ROUTES = {"/login", "/login.html"}
+H5_DOCUMENT_ROUTES = {"/h5", "/h5.html"}
+
+
+def _paged_success(
+    data: list[Dict[str, Any]],
+    *,
+    total: int,
+    limit: int,
+    offset: int,
+) -> Dict[str, Any]:
+    return success(
+        data,
+        pagination={
+            "total": total,
+            "limit": limit,
+            "offset": offset,
+            "has_previous": offset > 0,
+            "has_next": offset + len(data) < total,
+        },
+    )
 
 
 def _strict_json(raw: bytes) -> Mapping[str, Any]:
@@ -78,6 +113,19 @@ def _strict_json(raw: bytes) -> Mapping[str, Any]:
     return value
 
 
+def _decode_filename_header(
+    encoded: str,
+    *,
+    code: str,
+    safe_message: str,
+) -> str:
+    try:
+        padding = "=" * (-len(encoded) % 4)
+        return base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
+    except (ValueError, UnicodeDecodeError):
+        raise PortalError(code, safe_message) from None
+
+
 @dataclass(frozen=True)
 class RuntimeHealth:
     database_ready: bool
@@ -85,39 +133,75 @@ class RuntimeHealth:
     monthly_ready: bool
     download_ready: bool = False
     company_reports_ready: bool = False
-    agent_writeback_ready: bool = False
+    company_source_upload_ready: bool = False
+
+
+@dataclass(frozen=True)
+class _SessionRecord:
+    username: str
+    csrf: str
+    expires_at: float
 
 
 class SessionLedger:
-    """Small in-process CSRF ledger; deployment can replace it with shared sessions."""
+    """Small authenticated-session ledger for the single-process Web runtime."""
 
     def __init__(self, lifetime_seconds: int = 8 * 60 * 60) -> None:
         self._lifetime = lifetime_seconds
         self._lock = threading.Lock()
-        self._sessions: Dict[str, tuple[str, float]] = {}
+        self._sessions: Dict[str, _SessionRecord] = {}
 
-    def issue(self) -> tuple[str, str]:
+    @property
+    def lifetime_seconds(self) -> int:
+        return self._lifetime
+
+    def issue(self, username: str) -> tuple[str, str]:
         session_id = secrets.token_urlsafe(24)
         csrf = secrets.token_urlsafe(24)
         now = time.time()
         with self._lock:
-            self._sessions[session_id] = (csrf, now + self._lifetime)
+            self._sessions[session_id] = _SessionRecord(
+                username=username,
+                csrf=csrf,
+                expires_at=now + self._lifetime,
+            )
             if len(self._sessions) > 2048:
                 self._sessions = {
-                    key: value for key, value in self._sessions.items() if value[1] > now
+                    key: value
+                    for key, value in self._sessions.items()
+                    if value.expires_at > now
                 }
+                if len(self._sessions) > 2048:
+                    oldest = sorted(
+                        self._sessions,
+                        key=lambda key: self._sessions[key].expires_at,
+                    )
+                    for key in oldest[: len(self._sessions) - 2048]:
+                        self._sessions.pop(key, None)
         return session_id, csrf
 
+    def current(self, session_id: Optional[str]) -> Optional[tuple[str, str]]:
+        if not session_id:
+            return None
+        now = time.time()
+        with self._lock:
+            value = self._sessions.get(session_id)
+            if value is None or value.expires_at <= now:
+                self._sessions.pop(session_id, None)
+                return None
+            return value.username, value.csrf
+
     def verify(self, session_id: Optional[str], csrf: Optional[str]) -> bool:
         if not session_id or not csrf:
             return False
-        now = time.time()
+        current = self.current(session_id)
+        return current is not None and secrets.compare_digest(current[1], csrf)
+
+    def revoke(self, session_id: Optional[str]) -> None:
+        if not session_id:
+            return
         with self._lock:
-            value = self._sessions.get(session_id)
-            if value is None or value[1] <= now:
-                self._sessions.pop(session_id, None)
-                return False
-            return secrets.compare_digest(value[0], csrf)
+            self._sessions.pop(session_id, None)
 
 
 class PortalApplication:
@@ -125,27 +209,33 @@ class PortalApplication:
         self,
         repository: Optional[PortalRepository] = None,
         upload: Optional[UploadCoordinator] = None,
-        agent_results: Optional[AgentResultCoordinator] = None,
         monthly: Optional[MonthlyCoordinator] = None,
         company_reports: Optional[CompanyReportCoordinator] = None,
+        booking_sources: Optional[BookingSourceCoordinator] = None,
         artifact_reader: Optional[ArtifactReader] = None,
         *,
         health: Optional[RuntimeHealth] = None,
         static_root: Path = STATIC_ROOT,
         sessions: Optional[SessionLedger] = None,
+        credentials: Optional[LoginCredentials] = None,
+        login_attempts: Optional[LoginAttemptLedger] = None,
         secure_cookies: bool = False,
     ) -> None:
         self._repository = repository or UnavailablePortalRepository()
         self._upload = upload or UnavailableUploadCoordinator()
-        self._agent_results = agent_results or UnavailableAgentResultCoordinator()
         self._monthly = monthly or UnavailableMonthlyCoordinator()
         self._company_reports = (
             company_reports or UnavailableCompanyReportCoordinator()
         )
+        self._booking_sources = (
+            booking_sources or UnavailableBookingSourceCoordinator()
+        )
         self._artifact_reader = artifact_reader or UnavailableArtifactReader()
         self._health = health or RuntimeHealth(False, False, False)
         self._static_root = static_root.resolve()
         self._sessions = sessions or SessionLedger()
+        self._credentials = credentials
+        self._login_attempts = login_attempts or LoginAttemptLedger()
         self._secure_cookies = secure_cookies
 
     def handle(
@@ -154,30 +244,44 @@ class PortalApplication:
         target: str,
         headers: Mapping[str, str],
         body: bytes = b"",
+        client_id: str = "direct",
     ) -> Response:
         normalized_headers = {key.lower(): value for key, value in headers.items()}
         route = urlsplit(target)
         try:
+            session_id = self._session_id(normalized_headers)
+            current_session = self._sessions.current(session_id)
+            if method == "GET" and route.path == "/healthz":
+                return self._readiness()
+            if method == "GET" and route.path in LOGIN_STATIC_ROUTES:
+                if route.path in LOGIN_DOCUMENT_ROUTES and current_session is not None:
+                    return self._redirect(self._safe_next(route.query))
+                return self._static(route.path)
+            if method == "POST" and route.path == "/api/login":
+                return self._login(body, normalized_headers, client_id)
+            if current_session is None:
+                return self._authentication_required(method, route.path)
             if method == "GET" and route.path in STATIC_ROUTES:
                 return self._static(route.path)
             if method == "GET" and route.path == "/api/session":
-                session_id, csrf = self._sessions.issue()
-                cookie = (
-                    f"arr_session={session_id}; Path=/; HttpOnly; "
-                    "SameSite=Strict; Max-Age=28800"
-                )
-                if self._secure_cookies:
-                    cookie += "; Secure"
                 return Response.json(
                     200,
                     success(
                         {
-                            "csrf_token": csrf,
+                            "username": current_session[0],
+                            "csrf_token": current_session[1],
                             "max_upload_bytes": MAX_UPLOAD_BYTES,
                             "retention": "month_minimum",
                         }
                     ),
-                    {"Set-Cookie": cookie},
+                )
+            if method == "POST" and route.path == "/api/logout":
+                self._require_csrf(normalized_headers)
+                self._sessions.revoke(session_id)
+                return Response.json(
+                    200,
+                    success({"logged_out": True}),
+                    {"Set-Cookie": self._session_cookie("", max_age=0)},
                 )
             if method == "GET" and route.path == "/api/health":
                 return Response.json(
@@ -189,7 +293,7 @@ class PortalApplication:
                             "monthly_ready": self._health.monthly_ready,
                             "download_ready": self._health.download_ready,
                             "company_reports_ready": self._health.company_reports_ready,
-                            "agent_writeback_ready": self._health.agent_writeback_ready,
+                            "company_source_upload_ready": self._health.company_source_upload_ready,
                             "business_time_zone": "Asia/Bangkok",
                             "checked_at": datetime.now(ZoneInfo("Asia/Bangkok")).isoformat(),
                         }
@@ -198,15 +302,48 @@ class PortalApplication:
             if method == "GET" and route.path == "/api/jobs":
                 query = parse_qs(route.query, keep_blank_values=True)
                 month = validate_month(self._one(query, "month", self._current_month()))
-                limit = self._integer(query, "limit", 100, 1, 200)
-                return Response.json(200, success(self._repository.list_jobs(month, limit)))
+                limit = self._integer(query, "limit", 50, 1, 200)
+                offset = self._integer(query, "offset", 0, 0, 10_000_000)
+                jobs, total = self._repository.list_jobs(month, limit, offset)
+                return Response.json(
+                    200,
+                    _paged_success(
+                        jobs,
+                        total=total,
+                        limit=limit,
+                        offset=offset,
+                    ),
+                )
+            job_parts = route.path.strip("/").split("/")
+            if (
+                method == "GET"
+                and len(job_parts) == 4
+                and job_parts[:2] == ["api", "jobs"]
+                and job_parts[3] == "trace"
+            ):
+                job_id = validate_job_id(job_parts[2])
+                return Response.json(
+                    200,
+                    success(self._repository.get_job_trace(job_id)),
+                )
             if method == "GET" and route.path == "/api/monthly-runs":
                 query = parse_qs(route.query, keep_blank_values=True)
                 month = validate_month(self._one(query, "month", self._current_month()))
-                limit = self._integer(query, "limit", 100, 1, 200)
+                limit = self._integer(query, "limit", 50, 1, 200)
+                offset = self._integer(query, "offset", 0, 0, 10_000_000)
+                runs, total = self._repository.list_monthly_runs(
+                    month,
+                    limit,
+                    offset,
+                )
                 return Response.json(
                     200,
-                    success(self._repository.list_monthly_runs(month, limit)),
+                    _paged_success(
+                        runs,
+                        total=total,
+                        limit=limit,
+                        offset=offset,
+                    ),
                 )
             if method == "GET" and route.path == "/api/months":
                 return Response.json(200, success(self._repository.list_months()))
@@ -275,13 +412,34 @@ class PortalApplication:
                 )
             if method == "GET" and route.path == "/api/company-reports/jobs":
                 query = parse_qs(route.query, keep_blank_values=True)
-                limit = self._integer(query, "limit", 100, 1, 200)
+                limit = self._integer(query, "limit", 50, 1, 200)
+                offset = self._integer(query, "offset", 0, 0, 10_000_000)
                 report_month = None
                 if "month" in query:
                     report_month = validate_month(self._one(query, "month", ""))
+                jobs, total = self._company_reports.list_jobs(
+                    report_month,
+                    limit,
+                    offset,
+                )
                 return Response.json(
                     200,
-                    success(self._company_reports.list_jobs(report_month, limit)),
+                    _paged_success(
+                        jobs,
+                        total=total,
+                        limit=limit,
+                        offset=offset,
+                    ),
+                )
+            if method == "GET" and route.path == "/api/company-reports/source":
+                return Response.json(200, success(self._booking_sources.current()))
+            if method == "GET" and route.path == "/api/company-reports/source/draft":
+                query = parse_qs(route.query, keep_blank_values=True)
+                limit = self._integer(query, "limit", 50, 1, 200)
+                offset = self._integer(query, "offset", 0, 0, 10_000_000)
+                return Response.json(
+                    200,
+                    success(self._booking_sources.draft(limit, offset)),
                 )
             company_parts = route.path.strip("/").split("/")
             if (
@@ -320,29 +478,124 @@ class PortalApplication:
                 return self._download(self._repository.resolve_monthly_download(report_id))
             if method == "POST" and route.path == "/api/jobs":
                 self._require_csrf(normalized_headers)
-                encoded = normalized_headers.get("x-arr-filename-b64", "")
-                try:
-                    padding = "=" * (-len(encoded) % 4)
-                    filename = base64.urlsafe_b64decode(encoded + padding).decode("utf-8")
-                except (ValueError, UnicodeDecodeError):
-                    raise PortalError("UPLOAD_FILENAME_INVALID", "请选择 XML 文件") from None
+                filename = _decode_filename_header(
+                    normalized_headers.get("x-arr-filename-b64", ""),
+                    code="UPLOAD_FILENAME_INVALID",
+                    safe_message="请选择 XML 文件",
+                )
                 filename = validate_upload_filename(filename)
                 validate_xml_payload(body)
                 return Response.json(202, success(self._upload.submit(filename, body)))
-            if (
-                method == "POST"
-                and route.path == "/api/integrations/super-agent/results"
-            ):
-                content_type = normalized_headers.get("content-type", "").split(";", 1)[0]
-                if content_type.strip().lower() != "application/json":
+            if method == "POST" and route.path == "/api/company-reports/source":
+                self._require_csrf(normalized_headers)
+                filename = _decode_filename_header(
+                    normalized_headers.get("x-arr-filename-b64", ""),
+                    code="BOOKING_EXCEL_FILENAME_INVALID",
+                    safe_message="请选择 .xlsx 格式的 Excel 文件",
+                )
+                return Response.json(
+                    200,
+                    success(self._booking_sources.submit(filename, body)),
+                )
+            if method == "DELETE" and route.path == "/api/company-reports/source/draft/items":
+                self._require_csrf(normalized_headers)
+                if len(body) > 8192:
+                    raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
+                payload = _strict_json(body)
+                if set(payload) != {"draft_id", "item_ids"}:
                     raise PortalError(
-                        "CONTENT_TYPE_INVALID",
-                        "Agent 回写必须使用 application/json",
-                        415,
+                        "BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
+                        "批量删除记录请求无效",
                     )
                 return Response.json(
                     200,
-                    success(self._agent_results.accept(body)),
+                    success(
+                        self._booking_sources.delete_items(
+                            payload.get("draft_id"),
+                            payload.get("item_ids"),
+                        )
+                    ),
+                )
+            if (
+                method in {"PATCH", "DELETE"}
+                and len(company_parts) == 6
+                and company_parts[:5]
+                == ["api", "company-reports", "source", "draft", "items"]
+            ):
+                self._require_csrf(normalized_headers)
+                try:
+                    item_id = int(company_parts[5])
+                except ValueError:
+                    raise PortalError(
+                        "BOOKING_EXCEL_REVIEW_ITEM_INVALID",
+                        "待确认记录编号无效",
+                    ) from None
+                if item_id <= 0:
+                    raise PortalError(
+                        "BOOKING_EXCEL_REVIEW_ITEM_INVALID",
+                        "待确认记录编号无效",
+                    )
+                if len(body) > 8192:
+                    raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
+                payload = _strict_json(body)
+                if method == "PATCH":
+                    if set(payload) != {"draft_id", "room_type", "quantity"}:
+                        raise PortalError(
+                            "BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
+                            "人工确认字段无效",
+                        )
+                    return Response.json(
+                        200,
+                        success(
+                            self._booking_sources.update_item(
+                                payload.get("draft_id"),
+                                item_id,
+                                payload.get("room_type"),
+                                payload.get("quantity"),
+                            )
+                        ),
+                    )
+                if set(payload) != {"draft_id"}:
+                    raise PortalError(
+                        "BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
+                        "删除记录请求无效",
+                    )
+                return Response.json(
+                    200,
+                    success(
+                        self._booking_sources.delete_item(
+                            payload.get("draft_id"),
+                            item_id,
+                        )
+                    ),
+                )
+            if method == "DELETE" and route.path == "/api/company-reports/source/draft":
+                self._require_csrf(normalized_headers)
+                if len(body) > 8192:
+                    raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
+                payload = _strict_json(body)
+                if set(payload) != {"draft_id"}:
+                    raise PortalError(
+                        "BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
+                        "放弃提取请求无效",
+                    )
+                return Response.json(
+                    200,
+                    success(self._booking_sources.discard(payload.get("draft_id"))),
+                )
+            if method == "POST" and route.path == "/api/company-reports/source/draft/activate":
+                self._require_csrf(normalized_headers)
+                if len(body) > 8192:
+                    raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
+                payload = _strict_json(body)
+                if set(payload) != {"draft_id"}:
+                    raise PortalError(
+                        "BOOKING_EXCEL_REVIEW_REQUEST_INVALID",
+                        "确认启用请求无效",
+                    )
+                return Response.json(
+                    200,
+                    success(self._booking_sources.activate(payload.get("draft_id"))),
                 )
             if method == "POST" and route.path == "/api/monthly-runs":
                 self._require_csrf(normalized_headers)
@@ -359,6 +612,18 @@ class PortalApplication:
                 return Response.json(202, success(self._monthly.generate(month, as_of)))
             if method == "POST" and route.path == "/api/company-reports/jobs":
                 self._require_csrf(normalized_headers)
+                if self._booking_sources.draft(1, 0) is not None:
+                    raise PortalError(
+                        "BOOKING_EXCEL_REVIEW_OPEN",
+                        "请先完成或放弃当前 Excel 提取草稿",
+                        409,
+                    )
+                if self._booking_sources.current() is None:
+                    raise PortalError(
+                        "BOOKING_EXCEL_SOURCE_REQUIRED",
+                        "请先上传并校验 Excel 报表",
+                        409,
+                    )
                 if len(body) > 8192:
                     raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
                 payload = _strict_json(body)
@@ -381,7 +646,7 @@ class PortalApplication:
             raise PortalError("ROUTE_NOT_FOUND", "页面或接口不存在", 404)
         except PortalDataError as error:
             status = 503
-            if error.code == "DOWNLOAD_NOT_FOUND":
+            if error.code in {"DOWNLOAD_NOT_FOUND", "JOB_NOT_FOUND"}:
                 status = 404
             elif error.code == "DOWNLOAD_REQUEST_INVALID":
                 status = 400
@@ -397,6 +662,119 @@ class PortalApplication:
                 failure(PortalError("INTERNAL_ERROR", "服务暂时无法完成请求", 500)),
             )
 
+    def _readiness(self) -> Response:
+        ready = self._health.database_ready and self._health.upload_ready
+        return Response(
+            200 if ready else 503,
+            b"ready\n" if ready else b"unavailable\n",
+            "text/plain; charset=utf-8",
+            {"Cache-Control": "no-store"},
+        )
+
+    def _login(
+        self,
+        body: bytes,
+        headers: Mapping[str, str],
+        client_id: str,
+    ) -> Response:
+        media_type = headers.get("content-type", "").split(";", 1)[0].strip().lower()
+        if media_type != "application/json":
+            raise PortalError("LOGIN_REQUEST_INVALID", "登录请求格式无效", 415)
+        if len(body) > 8192:
+            raise PortalError("REQUEST_TOO_LARGE", "请求内容过大", 413)
+        retry_after = self._login_attempts.retry_after(client_id)
+        if retry_after:
+            return self._login_rate_limited(retry_after)
+        payload = _strict_json(body)
+        if set(payload) != {"username", "password"}:
+            raise PortalError("LOGIN_REQUEST_INVALID", "登录请求字段无效")
+        username = payload.get("username")
+        password = payload.get("password")
+        if self._credentials is None or not self._credentials.verify(
+            username,
+            password,
+        ):
+            retry_after = self._login_attempts.record_failure(client_id)
+            if retry_after:
+                return self._login_rate_limited(retry_after)
+            raise PortalError("LOGIN_FAILED", "用户名或密码不正确", 401)
+        assert isinstance(username, str)
+        self._login_attempts.record_success(client_id)
+        self._sessions.revoke(self._session_id(headers))
+        session_id, csrf = self._sessions.issue(username)
+        return Response.json(
+            200,
+            success(
+                {
+                    "username": username,
+                    "csrf_token": csrf,
+                    "expires_in": self._sessions.lifetime_seconds,
+                }
+            ),
+            {"Set-Cookie": self._session_cookie(session_id)},
+        )
+
+    @staticmethod
+    def _login_rate_limited(retry_after: int) -> Response:
+        return Response.json(
+            429,
+            failure(
+                PortalError(
+                    "LOGIN_RATE_LIMITED",
+                    "登录尝试过于频繁,请稍后再试",
+                    429,
+                )
+            ),
+            {"Retry-After": str(retry_after)},
+        )
+
+    def _authentication_required(self, method: str, route_path: str) -> Response:
+        if method == "GET" and not route_path.startswith("/api/"):
+            next_path = "/h5" if route_path in H5_DOCUMENT_ROUTES else "/"
+            return self._redirect(f"/login?next={quote(next_path, safe='')}")
+        return Response.json(
+            401,
+            failure(PortalError("AUTH_REQUIRED", "请先登录", 401)),
+        )
+
+    @staticmethod
+    def _redirect(location: str) -> Response:
+        return Response(
+            303,
+            b"",
+            "text/plain; charset=utf-8",
+            {"Cache-Control": "no-store", "Location": location},
+        )
+
+    @staticmethod
+    def _safe_next(query: str) -> str:
+        values = parse_qs(query, keep_blank_values=True).get("next", [])
+        if len(values) == 1 and values[0] in {"/", "/h5"}:
+            return values[0]
+        return "/"
+
+    @staticmethod
+    def _session_id(headers: Mapping[str, str]) -> Optional[str]:
+        cookie = SimpleCookie()
+        try:
+            cookie.load(headers.get("cookie", ""))
+        except Exception:
+            return None
+        item = cookie.get("arr_session")
+        return item.value if item is not None else None
+
+    def _session_cookie(self, session_id: str, *, max_age: Optional[int] = None) -> str:
+        age = self._sessions.lifetime_seconds if max_age is None else max_age
+        cookie = (
+            f"arr_session={session_id}; Path=/; HttpOnly; "
+            f"SameSite=Strict; Max-Age={age}"
+        )
+        if age == 0:
+            cookie += "; Expires=Thu, 01 Jan 1970 00:00:00 GMT"
+        if self._secure_cookies:
+            cookie += "; Secure"
+        return cookie
+
     def _static(self, route: str) -> Response:
         filename, mime_type = STATIC_ROUTES[route]
         path = (self._static_root / filename).resolve()
@@ -425,13 +803,7 @@ class PortalApplication:
         )
 
     def _require_csrf(self, headers: Mapping[str, str]) -> None:
-        cookie = SimpleCookie()
-        try:
-            cookie.load(headers.get("cookie", ""))
-        except Exception:
-            raise PortalError("SESSION_INVALID", "页面会话已失效", 403) from None
-        item = cookie.get("arr_session")
-        session_id = item.value if item is not None else None
+        session_id = self._session_id(headers)
         if not self._sessions.verify(session_id, headers.get("x-arr-csrf")):
             raise PortalError("SESSION_INVALID", "页面会话已失效", 403)
 
diff --git a/arr_web/auth.py b/arr_web/auth.py
new file mode 100644
index 0000000..65c1fef
--- /dev/null
+++ b/arr_web/auth.py
@@ -0,0 +1,157 @@
+"""Dependency-free login credentials and bounded attempt tracking."""
+
+from __future__ import annotations
+
+import math
+import os
+import secrets
+import threading
+import time
+from dataclasses import dataclass, field
+from typing import Callable, Dict, Mapping, Optional
+
+
+USERNAME_ENV = "ARR_WEB_USERNAME"
+PASSWORD_ENV = "ARR_WEB_PASSWORD"
+MIN_PASSWORD_LENGTH = 12
+MAX_USERNAME_LENGTH = 128
+MAX_PASSWORD_LENGTH = 1024
+
+
+@dataclass(frozen=True)
+class LoginCredentials:
+    """One environment-owned operator identity for the current portal deployment."""
+
+    username: str
+    password: str
+
+    def __post_init__(self) -> None:
+        if (
+            not self.username
+            or self.username != self.username.strip()
+            or len(self.username) > MAX_USERNAME_LENGTH
+        ):
+            raise ValueError("ARR Web login username is invalid")
+        if not MIN_PASSWORD_LENGTH <= len(self.password) <= MAX_PASSWORD_LENGTH:
+            raise ValueError(
+                f"ARR Web login password must be {MIN_PASSWORD_LENGTH}–"
+                f"{MAX_PASSWORD_LENGTH} characters"
+            )
+
+    @classmethod
+    def from_environment(
+        cls,
+        environ: Optional[Mapping[str, str]] = None,
+    ) -> "LoginCredentials":
+        source = os.environ if environ is None else environ
+        username = source.get(USERNAME_ENV, "")
+        password = source.get(PASSWORD_ENV, "")
+        if not username or not password:
+            raise ValueError(
+                f"ARR Web login requires {USERNAME_ENV} and {PASSWORD_ENV}"
+            )
+        return cls(username=username, password=password)
+
+    def verify(self, username: object, password: object) -> bool:
+        if not isinstance(username, str) or not isinstance(password, str):
+            return False
+        if (
+            len(username) > MAX_USERNAME_LENGTH
+            or len(password) > MAX_PASSWORD_LENGTH
+        ):
+            return False
+        username_matches = secrets.compare_digest(
+            username.encode("utf-8"),
+            self.username.encode("utf-8"),
+        )
+        password_matches = secrets.compare_digest(
+            password.encode("utf-8"),
+            self.password.encode("utf-8"),
+        )
+        return username_matches and password_matches
+
+
+@dataclass
+class _AttemptState:
+    failures: list[float] = field(default_factory=list)
+    blocked_until: float = 0.0
+
+
+class LoginAttemptLedger:
+    """Small per-client sliding-window limiter for the single-process Web runtime."""
+
+    def __init__(
+        self,
+        *,
+        max_failures: int = 5,
+        window_seconds: int = 5 * 60,
+        block_seconds: int = 60,
+        clock: Callable[[], float] = time.monotonic,
+    ) -> None:
+        if max_failures < 1 or window_seconds < 1 or block_seconds < 1:
+            raise ValueError("login attempt limits must be positive")
+        self._max_failures = max_failures
+        self._window = float(window_seconds)
+        self._block = float(block_seconds)
+        self._clock = clock
+        self._lock = threading.Lock()
+        self._states: Dict[str, _AttemptState] = {}
+
+    def retry_after(self, client_id: str) -> int:
+        now = self._clock()
+        key = self._key(client_id)
+        with self._lock:
+            state = self._states.get(key)
+            if state is None:
+                return 0
+            self._prune(state, now)
+            if state.blocked_until > now:
+                return max(1, math.ceil(state.blocked_until - now))
+            if not state.failures:
+                self._states.pop(key, None)
+            return 0
+
+    def record_failure(self, client_id: str) -> int:
+        now = self._clock()
+        key = self._key(client_id)
+        with self._lock:
+            state = self._states.setdefault(key, _AttemptState())
+            self._prune(state, now)
+            if state.blocked_until > now:
+                return max(1, math.ceil(state.blocked_until - now))
+            state.failures.append(now)
+            if len(state.failures) >= self._max_failures:
+                state.failures.clear()
+                state.blocked_until = now + self._block
+                return max(1, math.ceil(self._block))
+            self._trim(now)
+            return 0
+
+    def record_success(self, client_id: str) -> None:
+        with self._lock:
+            self._states.pop(self._key(client_id), None)
+
+    def _prune(self, state: _AttemptState, now: float) -> None:
+        state.failures[:] = [
+            failure for failure in state.failures if failure > now - self._window
+        ]
+        if state.blocked_until <= now:
+            state.blocked_until = 0.0
+
+    def _trim(self, now: float) -> None:
+        if len(self._states) <= 4096:
+            return
+        stale = []
+        for key, state in self._states.items():
+            self._prune(state, now)
+            if not state.failures and state.blocked_until <= now:
+                stale.append(key)
+        for key in stale:
+            self._states.pop(key, None)
+        if len(self._states) > 4096:
+            for key in list(self._states)[: len(self._states) - 4096]:
+                self._states.pop(key, None)
+
+    @staticmethod
+    def _key(client_id: str) -> str:
+        return (client_id or "unknown")[:128]
diff --git a/arr_web/booking_uploads.py b/arr_web/booking_uploads.py
new file mode 100644
index 0000000..8dbb5bf
--- /dev/null
+++ b/arr_web/booking_uploads.py
@@ -0,0 +1,287 @@
+"""Authenticated Booking Excel extraction and review coordinator."""
+
+from __future__ import annotations
+
+import os
+import tempfile
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, Optional, Protocol
+
+from arr_ingestion.contracts import ArtifactRef, IngestionError
+from arr_storage.store import ManagedObjectStore
+from booking_ingestion.excel import (
+    BookingExcelError,
+    ExcelDocument,
+    parse_excel_bytes,
+    validate_excel_filename,
+)
+from booking_ingestion.excel_postgres import (
+    BookingExcelRepositoryError,
+    BookingSourceSummary,
+)
+from booking_ingestion.excel_review import (
+    BookingDraftItem,
+    BookingDraftPage,
+    validate_review_item_ids,
+)
+
+from .contracts import PortalError
+
+
+class BookingSourceRepository(Protocol):
+    def current_source(self) -> Optional[BookingSourceSummary]:
+        ...
+
+    def current_draft(
+        self,
+        *,
+        limit: int = 50,
+        offset: int = 0,
+    ) -> Optional[BookingDraftPage]:
+        ...
+
+    def create_draft(
+        self,
+        draft_id: str,
+        source: ArtifactRef,
+        document: ExcelDocument,
+        *,
+        limit: int = 50,
+    ) -> BookingDraftPage:
+        ...
+
+    def update_item(
+        self,
+        draft_id: str,
+        item_id: int,
+        room_type_code: str,
+        quantity: int,
+    ) -> BookingDraftItem:
+        ...
+
+    def delete_item(self, draft_id: str, item_id: int) -> None:
+        ...
+
+    def delete_items(self, draft_id: str, item_ids: object) -> None:
+        ...
+
+    def discard_draft(self, draft_id: str) -> None:
+        ...
+
+    def activate_draft(self, draft_id: str) -> BookingSourceSummary:
+        ...
+
+
+class BookingSourceCoordinator(Protocol):
+    def current(self) -> Optional[Dict[str, object]]:
+        ...
+
+    def draft(self, limit: int = 50, offset: int = 0) -> Optional[Dict[str, object]]:
+        ...
+
+    def submit(self, original_filename: str, payload: bytes) -> Dict[str, object]:
+        ...
+
+    def update_item(
+        self,
+        draft_id: str,
+        item_id: int,
+        room_type: str,
+        quantity: int,
+    ) -> Dict[str, object]:
+        ...
+
+    def delete_item(self, draft_id: str, item_id: int) -> Dict[str, object]:
+        ...
+
+    def delete_items(self, draft_id: str, item_ids: object) -> Dict[str, object]:
+        ...
+
+    def discard(self, draft_id: str) -> Dict[str, object]:
+        ...
+
+    def activate(self, draft_id: str) -> Dict[str, object]:
+        ...
+
+
+class UnavailableBookingSourceCoordinator:
+    @staticmethod
+    def _unavailable() -> PortalError:
+        return PortalError(
+            "BOOKING_EXCEL_SERVICE_UNAVAILABLE",
+            "Excel 数据源服务暂未就绪",
+            503,
+        )
+
+    def current(self) -> Optional[Dict[str, object]]:
+        raise self._unavailable()
+
+    def draft(self, limit: int = 50, offset: int = 0) -> Optional[Dict[str, object]]:
+        _ = (limit, offset)
+        raise self._unavailable()
+
+    def submit(self, original_filename: str, payload: bytes) -> Dict[str, object]:
+        _ = (original_filename, payload)
+        raise self._unavailable()
+
+    def update_item(
+        self,
+        draft_id: str,
+        item_id: int,
+        room_type: str,
+        quantity: int,
+    ) -> Dict[str, object]:
+        _ = (draft_id, item_id, room_type, quantity)
+        raise self._unavailable()
+
+    def delete_item(self, draft_id: str, item_id: int) -> Dict[str, object]:
+        _ = (draft_id, item_id)
+        raise self._unavailable()
+
+    def delete_items(self, draft_id: str, item_ids: object) -> Dict[str, object]:
+        _ = (draft_id, item_ids)
+        raise self._unavailable()
+
+    def discard(self, draft_id: str) -> Dict[str, object]:
+        _ = draft_id
+        raise self._unavailable()
+
+    def activate(self, draft_id: str) -> Dict[str, object]:
+        _ = draft_id
+        raise self._unavailable()
+
+
+@dataclass
+class ProgramBookingSourceCoordinator:
+    object_store: ManagedObjectStore
+    repository: BookingSourceRepository
+
+    def current(self) -> Optional[Dict[str, object]]:
+        try:
+            summary = self.repository.current_source()
+            return summary.to_dict() if summary is not None else None
+        except BookingExcelRepositoryError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+
+    def draft(self, limit: int = 50, offset: int = 0) -> Optional[Dict[str, object]]:
+        try:
+            page = self.repository.current_draft(limit=limit, offset=offset)
+            return page.to_dict() if page is not None else None
+        except BookingExcelError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+        except BookingExcelRepositoryError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+
+    def submit(self, original_filename: str, payload: bytes) -> Dict[str, object]:
+        try:
+            filename = validate_excel_filename(original_filename)
+            document = parse_excel_bytes(payload)
+            draft_id = "bookingdraft-" + uuid.uuid4().hex
+            with tempfile.TemporaryDirectory(prefix="arr-booking-excel-") as temporary:
+                source_path = Path(temporary) / "booking-source.xlsx"
+                self._write_private(source_path, payload)
+                stored = self.object_store.upload_committed(
+                    job_id=draft_id,
+                    attempt_no=1,
+                    role="booking_source",
+                    source=source_path,
+                    original_filename=filename,
+                    expected_sha256=document.source_sha256,
+                    expected_byte_size=document.source_byte_size,
+                )
+                page = self.repository.create_draft(
+                    draft_id,
+                    stored.to_artifact_ref(),
+                    document,
+                )
+            return page.to_dict()
+        except PortalError:
+            raise
+        except BookingExcelError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+        except BookingExcelRepositoryError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+        except IngestionError as error:
+            retryable = error.retryable or error.code.startswith("OBJECT_")
+            raise PortalError(
+                error.code,
+                error.safe_message,
+                503 if retryable else 422,
+            ) from None
+        except Exception:
+            raise PortalError(
+                "BOOKING_EXCEL_PROCESSING_FAILED",
+                "Excel 文件未能完成提取",
+                503,
+            ) from None
+
+    def update_item(
+        self,
+        draft_id: str,
+        item_id: int,
+        room_type: str,
+        quantity: int,
+    ) -> Dict[str, object]:
+        try:
+            return self.repository.update_item(
+                draft_id,
+                item_id,
+                room_type,
+                quantity,
+            ).to_dict()
+        except BookingExcelError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+        except BookingExcelRepositoryError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+
+    def delete_item(self, draft_id: str, item_id: int) -> Dict[str, object]:
+        result = self.delete_items(draft_id, [item_id])
+        return {"draft_id": draft_id, "item_id": item_id, "deleted": result["deleted"]}
+
+    def delete_items(self, draft_id: str, item_ids: object) -> Dict[str, object]:
+        try:
+            validated_ids = validate_review_item_ids(item_ids)
+            self.repository.delete_items(draft_id, validated_ids)
+            return {
+                "draft_id": draft_id,
+                "item_ids": list(validated_ids),
+                "deleted_count": len(validated_ids),
+                "deleted": True,
+            }
+        except BookingExcelError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+        except BookingExcelRepositoryError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+
+    def discard(self, draft_id: str) -> Dict[str, object]:
+        try:
+            self.repository.discard_draft(draft_id)
+            return {"draft_id": draft_id, "discarded": True}
+        except BookingExcelError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+        except BookingExcelRepositoryError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+
+    def activate(self, draft_id: str) -> Dict[str, object]:
+        try:
+            return self.repository.activate_draft(draft_id).to_dict()
+        except BookingExcelError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+        except BookingExcelRepositoryError as error:
+            raise PortalError(error.code, error.safe_message, error.status) from None
+
+    @staticmethod
+    def _write_private(path: Path, payload: bytes) -> None:
+        path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+        descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+        try:
+            with os.fdopen(descriptor, "wb") as target:
+                descriptor = -1
+                target.write(payload)
+                target.flush()
+                os.fsync(target.fileno())
+        finally:
+            if descriptor >= 0:
+                os.close(descriptor)
diff --git a/arr_web/company_jobs.py b/arr_web/company_jobs.py
index 72a6bf5..d97e6a5 100644
--- a/arr_web/company_jobs.py
+++ b/arr_web/company_jobs.py
@@ -62,7 +62,7 @@ def period_label(report_month: str, period: str) -> str:
     return f"21-{as_of.day:02d}"
 
 
-def period_release_at(report_month: str, period: str) -> datetime:
+def period_complete_at(report_month: str, period: str) -> datetime:
     period_to_as_of(report_month, period)
     year, month = (int(part) for part in report_month.split("-"))
     if period == "01-10":
@@ -76,6 +76,12 @@ def period_release_at(report_month: str, period: str) -> datetime:
     return datetime.combine(released, datetime_time.min, COMPANY_REPORT_TIME_ZONE)
 
 
+def period_release_at(report_month: str, period: str) -> datetime:
+    """Backward-compatible alias for the period completion boundary."""
+
+    return period_complete_at(report_month, period)
+
+
 def _utc_now() -> str:
     return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
 
@@ -122,8 +128,9 @@ class CompanyReportCoordinator(Protocol):
     def list_jobs(
         self,
         report_month: Optional[str] = None,
-        limit: int = 100,
-    ) -> List[Dict[str, Any]]:
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         ...
 
     def get_job(self, job_id: str) -> Dict[str, Any]:
@@ -148,8 +155,9 @@ class UnavailableCompanyReportCoordinator:
     def list_jobs(
         self,
         report_month: Optional[str] = None,
-        limit: int = 100,
-    ) -> List[Dict[str, Any]]:
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         raise self._unavailable()
 
     def get_job(self, job_id: str) -> Dict[str, Any]:
@@ -212,11 +220,13 @@ class PersistentCompanyReportCoordinator:
         if current.tzinfo is None:
             current = current.replace(tzinfo=COMPANY_REPORT_TIME_ZONE)
         current = current.astimezone(COMPANY_REPORT_TIME_ZONE)
-        released = period_release_at(report_month, period)
-        if current < released:
+        report_year, report_month_number = (
+            int(part) for part in report_month.split("-")
+        )
+        if (report_year, report_month_number) > (current.year, current.month):
             raise PortalError(
-                "COMPANY_REPORT_PERIOD_NOT_OPEN",
-                f"该期间将于曼谷时间 {released.strftime('%Y-%m-%d %H:%M')} 开放",
+                "COMPANY_REPORT_MONTH_IN_FUTURE",
+                "未来报表月份暂不可生成",
                 409,
             )
         with self._condition:
@@ -260,11 +270,17 @@ class PersistentCompanyReportCoordinator:
     def list_jobs(
         self,
         report_month: Optional[str] = None,
-        limit: int = 100,
-    ) -> List[Dict[str, Any]]:
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         if report_month is not None:
             validate_month(report_month)
-        if not 1 <= limit <= 200:
+        if (
+            isinstance(limit, bool)
+            or isinstance(offset, bool)
+            or not 1 <= limit <= 200
+            or not 0 <= offset <= 10_000_000
+        ):
             raise PortalError("QUERY_INVALID", "查询参数无效")
         with self._condition:
             states = self._states_locked()
@@ -272,7 +288,9 @@ class PersistentCompanyReportCoordinator:
                 states = [
                     state for state in states if state.get("report_month") == report_month
                 ]
-            return [self._public_locked(state) for state in states[:limit]]
+            total = len(states)
+            page = states[offset : offset + limit]
+            return [self._public_locked(state) for state in page], total
 
     def get_job(self, job_id: str) -> Dict[str, Any]:
         with self._condition:
@@ -667,7 +685,7 @@ class PersistentCompanyReportCoordinator:
             message = {
                 "succeeded": "5 家公司渠道明细表已生成。",
                 "partial_failure": "部分公司已生成,失败公司可按错误代码复核后重跑。",
-                "failed": "本次未生成正式渠道明细表,请按错误代码复核后重跑。",
+                "failed": None,
             }[job_state]
             with self._condition:
                 self._update_locked(
diff --git a/arr_web/contracts.py b/arr_web/contracts.py
index 0d7f436..a1b8355 100644
--- a/arr_web/contracts.py
+++ b/arr_web/contracts.py
@@ -12,6 +12,7 @@ API_VERSION = "1.0"
 MAX_UPLOAD_BYTES = 25 * 1024 * 1024
 MONTH_RE = re.compile(r"^(\d{4})-(\d{2})$")
 SAFE_FILENAME_RE = re.compile(r"^[^/\\\x00-\x1f\x7f]{1,255}$")
+JOB_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
 
 
 class PortalError(RuntimeError):
@@ -42,6 +43,12 @@ def validate_upload_filename(value: object) -> str:
     return value
 
 
+def validate_job_id(value: object) -> str:
+    if not isinstance(value, str) or JOB_ID_RE.fullmatch(value) is None:
+        raise PortalError("JOB_ID_INVALID", "任务编号无效")
+    return value
+
+
 def validate_xml_payload(value: bytes) -> None:
     if not value:
         raise PortalError("UPLOAD_EMPTY", "XML 文件为空")
diff --git a/arr_web/direct_ingestion_runtime.py b/arr_web/direct_ingestion_runtime.py
index c1eda43..bd98f6e 100644
--- a/arr_web/direct_ingestion_runtime.py
+++ b/arr_web/direct_ingestion_runtime.py
@@ -13,11 +13,11 @@ from arr_ingestion.direct_service import DirectSubmissionService
 from arr_ingestion.direct_validation import DirectResultValidator
 from arr_ingestion.postgres import DatabaseConfig
 from arr_ingestion.validation import ProcessorPolicy
+from arr_processing.policy import load_processor_policy
 from arr_storage.aliyun_oss_v2 import AliyunOssConfig, AliyunOssV2Client
 from arr_storage.contracts import ObjectKeyPolicy
 from arr_storage.remote import CloudObjectBackend
 from arr_storage.store import ManagedObjectStore
-from arr_web.agent_writeback_runtime import load_processor_policy
 
 
 @dataclass
diff --git a/arr_web/downloads.py b/arr_web/downloads.py
index 5ac7be3..cc72425 100644
--- a/arr_web/downloads.py
+++ b/arr_web/downloads.py
@@ -5,10 +5,13 @@ from __future__ import annotations
 import hashlib
 import os
 import stat
+import tempfile
 from dataclasses import dataclass
 from pathlib import Path, PurePosixPath
-from typing import Protocol
+from typing import Optional, Protocol
 
+from arr_ingestion.contracts import IngestionError
+from arr_storage.store import ManagedObjectStore
 from arr_web.contracts import PortalError
 
 
@@ -59,6 +62,77 @@ class UnavailableArtifactReader:
         raise PortalError("DOWNLOAD_UNAVAILABLE", "文件读取服务暂不可用", 503)
 
 
+class ManagedObjectArtifactReader:
+    """Read a committed daily XLSX from ARR's immutable object store."""
+
+    def __init__(self, object_store: ManagedObjectStore) -> None:
+        self._object_store = object_store
+
+    def read(self, descriptor: ArtifactDescriptor) -> bytes:
+        descriptor.validate()
+        if descriptor.file_kind != "daily_xlsx":
+            raise PortalError("DOWNLOAD_REFERENCE_INVALID", "文件身份无效", 500)
+        try:
+            stored = self._object_store.inspect_committed(
+                descriptor.storage_key,
+                descriptor.original_filename,
+            )
+            if (
+                stored.role != "daily_report"
+                or stored.sha256 != descriptor.sha256
+                or stored.byte_size != descriptor.byte_size
+                or stored.mime_type != descriptor.mime_type
+            ):
+                raise PortalError(
+                    "DOWNLOAD_IDENTITY_MISMATCH", "文件完整性校验失败", 503
+                )
+            with tempfile.TemporaryDirectory(prefix="arr-download-") as temporary:
+                destination = Path(temporary) / "daily.xlsx"
+                self._object_store.materialize(
+                    descriptor.storage_key,
+                    destination,
+                    MAX_DOWNLOAD_BYTES,
+                )
+                value = destination.read_bytes()
+        except PortalError:
+            raise
+        except (IngestionError, OSError):
+            raise PortalError(
+                "DOWNLOAD_UNAVAILABLE", "文件读取服务暂不可用", 503
+            ) from None
+        if (
+            len(value) != descriptor.byte_size
+            or hashlib.sha256(value).hexdigest() != descriptor.sha256
+        ):
+            raise PortalError(
+                "DOWNLOAD_IDENTITY_MISMATCH", "文件完整性校验失败", 503
+            )
+        return value
+
+
+class RoutedArtifactReader:
+    """Route OSS daily artifacts and controlled local report artifacts safely."""
+
+    def __init__(
+        self,
+        *,
+        daily_reader: Optional[ArtifactReader],
+        local_reader: ArtifactReader,
+    ) -> None:
+        self._daily_reader = daily_reader
+        self._local_reader = local_reader
+
+    def read(self, descriptor: ArtifactDescriptor) -> bytes:
+        descriptor.validate()
+        if descriptor.file_kind == "daily_xlsx":
+            if self._daily_reader is None:
+                raise PortalError(
+                    "DOWNLOAD_UNAVAILABLE", "文件读取服务暂不可用", 503
+                )
+            return self._daily_reader.read(descriptor)
+        return self._local_reader.read(descriptor)
+
+
 class ControlledProjectArtifactReader:
     """Development/local publication reader constrained to one project root."""
 
diff --git a/arr_web/job_trace.py b/arr_web/job_trace.py
new file mode 100644
index 0000000..f9ae0fe
--- /dev/null
+++ b/arr_web/job_trace.py
@@ -0,0 +1,654 @@
+"""Privacy-safe, structured task trace projection for the ARR portal."""
+
+from __future__ import annotations
+
+from datetime import date, datetime, timezone
+from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence
+
+
+ACTIVE_RUN_STATUSES = {"received", "queued", "running", "validating"}
+
+_CURRENT_STAGE = {
+    "received": "upload",
+    "queued": "processor",
+    "running": "processor",
+    "validating": "validation",
+    "accepted": "database",
+    "rejected": "validation",
+    "failed": "processor",
+    "cancelled": "processor",
+}
+
+_CURRENT_COPY = {
+    "received": ("当前:文件已接收", "等待创建程序化处理任务。"),
+    "queued": ("当前:等待程序处理", "任务已进入 ARR 处理队列。"),
+    "running": ("当前:ARR 正在处理", "固定处理器正在生成日报和完整结构化结果。"),
+    "validating": ("当前:正在独立验收", "ARR 正在复核文件身份、业务结果并准备数据库提交。"),
+}
+
+_LOG_ORDER = {
+    "UPLOAD_REGISTERED": 10,
+    "ATTEMPT_QUEUED": 20,
+    "PROCESSOR_STARTED": 30,
+    "ARTIFACT_RESULT_RECEIVED": 40,
+    "DIRECT_RESULT_RECEIVED": 40,
+    "ARTIFACT_RESULT_VALIDATED": 50,
+    "DIRECT_VALIDATION_STARTED": 50,
+    "FINANCE_VERSION_CREATED": 60,
+    "FINANCE_VERSION_VALIDATED": 70,
+    "FINANCE_VERSION_ACTIVATED": 80,
+    "ARTIFACT_RESULT_COMMITTED": 85,
+    "DIRECT_RESULT_COMMITTED": 85,
+    "DIRECT_RESULT_ALREADY_COMMITTED": 85,
+    "ATTEMPT_SUCCEEDED": 90,
+    "JOB_SUCCEEDED": 95,
+    "DOWNSTREAM_FAILURE_QUEUED": 100,
+    "DOWNSTREAM_EVENT_QUEUED": 100,
+    "DOWNSTREAM_EVENT_PUBLISHED": 110,
+}
+
+
+def _iso(value: Any) -> Optional[str]:
+    if value is None:
+        return None
+    if isinstance(value, (date, datetime)):
+        return value.isoformat()
+    return str(value)
+
+
+def _safe_text(value: Any, maximum: int = 500) -> Optional[str]:
+    if value is None:
+        return None
+    normalized = " ".join(str(value).split())
+    return normalized[:maximum] if normalized else None
+
+
+def _safe_details(values: Mapping[str, Any]) -> Dict[str, Any]:
+    output: Dict[str, Any] = {}
+    for key, value in values.items():
+        if value is None or value == "":
+            continue
+        if isinstance(value, (date, datetime)):
+            output[key] = value.isoformat()
+        elif isinstance(value, str):
+            output[key] = _safe_text(value, 256)
+        elif isinstance(value, (bool, int, float)):
+            output[key] = value
+    return output
+
+
+def _timestamp_key(value: str) -> float:
+    try:
+        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+        if parsed.tzinfo is None:
+            parsed = parsed.replace(tzinfo=timezone.utc)
+        return parsed.astimezone(timezone.utc).timestamp()
+    except (TypeError, ValueError):
+        return 0.0
+
+
+def _failure_stage(code: Any) -> str:
+    normalized = str(code or "").upper()
+    if normalized in {"PROCESSING_RESULT_MISSING", "SUBMISSION_EXPIRED"}:
+        return "processor"
+    if any(token in normalized for token in ("SUBMISSION", "CALLBACK", "DELIVERY")):
+        return "validation"
+    if any(
+        token in normalized
+        for token in ("VALIDATION", "CONTRACT", "SCHEMA", "MISMATCH", "SIGNATURE")
+    ):
+        return "validation"
+    if "DATABASE" in normalized:
+        return "database"
+    if any(token in normalized for token in ("UPLOAD", "OBJECT_STORE", "SOURCE")):
+        return "upload"
+    if any(token in normalized for token in ("DISPATCH", "TRANSPORT", "REMOTE", "AGENT", "PROCESSOR")):
+        return "processor"
+    return "processor"
+
+
+def _append_log(
+    logs: List[Dict[str, Any]],
+    *,
+    log_id: str,
+    timestamp: Any,
+    stage: str,
+    level: str,
+    code: str,
+    title: str,
+    message: str,
+    details: Optional[Mapping[str, Any]] = None,
+) -> None:
+    resolved_timestamp = _iso(timestamp)
+    if not resolved_timestamp:
+        return
+    item: Dict[str, Any] = {
+        "id": log_id,
+        "timestamp": resolved_timestamp,
+        "stage": stage,
+        "level": level,
+        "code": code,
+        "title": title,
+        "message": _safe_text(message, 1_200) or "",
+        "_order": _LOG_ORDER.get(code, 85),
+        "_sequence": len(logs),
+    }
+    safe_details = _safe_details(details or {})
+    if safe_details:
+        item["details"] = safe_details
+    logs.append(item)
+
+
+def _latest(values: Sequence[Mapping[str, Any]], key: str) -> Optional[Mapping[str, Any]]:
+    if not values:
+        return None
+    return max(values, key=lambda item: int(item.get(key) or 0))
+
+
+def _attempt_logs(logs: List[Dict[str, Any]], attempts: Sequence[Mapping[str, Any]]) -> None:
+    for attempt in attempts:
+        number = int(attempt.get("attempt_no") or 0)
+        details = {
+            "attempt_no": number,
+            "attempt_status": attempt.get("attempt_status"),
+            "remote_run_id": attempt.get("remote_run_id"),
+        }
+        _append_log(
+            logs,
+            log_id=f"attempt.{number}.queued",
+            timestamp=attempt.get("created_at"),
+            stage="processor",
+            level="info",
+            code="ATTEMPT_QUEUED",
+            title=f"处理尝试 #{number} 已创建",
+            message="任务已登记到 ARR 程序化处理队列。",
+            details=details,
+        )
+        if attempt.get("started_at"):
+            _append_log(
+                logs,
+                log_id=f"attempt.{number}.started",
+                timestamp=attempt.get("started_at"),
+                stage="processor",
+                level="info",
+                code="PROCESSOR_STARTED",
+                title="固定处理器已启动",
+                message="ARR 已开始处理已登记的源 XML。",
+                details=details,
+            )
+        status = str(attempt.get("attempt_status") or "")
+        if status in {"failed", "cancelled"} and attempt.get("finished_at"):
+            failure_code = attempt.get("failure_code")
+            _append_log(
+                logs,
+                log_id=f"attempt.{number}.{status}",
+                timestamp=attempt.get("finished_at"),
+                stage=_failure_stage(failure_code),
+                level="error" if status == "failed" else "warning",
+                code=str(failure_code or f"ATTEMPT_{status.upper()}"),
+                title="程序化处理失败" if status == "failed" else "程序化处理已取消",
+                message=(
+                    _safe_text(attempt.get("failure_message"))
+                    or "程序化处理尝试未完成。"
+                ),
+                details={**details, "failure_code": failure_code},
+            )
+        elif status == "succeeded" and attempt.get("finished_at"):
+            _append_log(
+                logs,
+                log_id=f"attempt.{number}.succeeded",
+                timestamp=attempt.get("finished_at"),
+                stage="database",
+                level="success",
+                code="ATTEMPT_SUCCEEDED",
+                title="处理尝试已闭环",
+                message="本地处理结果已通过 ARR 验收并完成业务提交。",
+                details=details,
+            )
+
+
+def _delivery_logs(
+    logs: List[Dict[str, Any]], deliveries: Sequence[Mapping[str, Any]]
+) -> None:
+    for index, delivery in enumerate(deliveries, 1):
+        identity = delivery.get("delivery_key") or index
+        details = {
+            "delivery_id": delivery.get("delivery_key"),
+            "attempt_no": delivery.get("attempt_no"),
+            "delivery_status": delivery.get("delivery_status"),
+            "result_status": delivery.get("result_status"),
+            "processor_version": delivery.get("processor_version"),
+            "business_date": delivery.get("business_date"),
+            "daily_version_id": delivery.get("daily_version_id"),
+        }
+        _append_log(
+            logs,
+            log_id=f"delivery.{identity}.received",
+            timestamp=delivery.get("received_at"),
+            stage="validation",
+            level="success",
+            code="ARTIFACT_RESULT_RECEIVED",
+            title="程序输出制品已登记",
+            message="ARR 已登记本地处理制品并开始独立验收。",
+            details=details,
+        )
+        if delivery.get("validated_at"):
+            _append_log(
+                logs,
+                log_id=f"delivery.{identity}.validated",
+                timestamp=delivery.get("validated_at"),
+                stage="validation",
+                level="success",
+                code="ARTIFACT_RESULT_VALIDATED",
+                title="结果制品已通过验收",
+                message="处理器版本、规则、文件身份与业务结果已完成复核。",
+                details=details,
+            )
+        status = str(delivery.get("delivery_status") or "")
+        if delivery.get("committed_at"):
+            failed = status in {"recorded_failure", "rejected"}
+            failure_code = delivery.get("failure_code")
+            _append_log(
+                logs,
+                log_id=f"delivery.{identity}.finished",
+                timestamp=delivery.get("committed_at"),
+                stage="validation" if failed else "database",
+                level="error" if failed else "success",
+                code=str(
+                    failure_code
+                    or ("ARTIFACT_RESULT_REJECTED" if failed else "ARTIFACT_RESULT_COMMITTED")
+                ),
+                title="结果制品未通过验收" if failed else "结果制品已提交数据库",
+                message=(
+                    _safe_text(delivery.get("failure_message"))
+                    if failed
+                    else "验收结果已原子提交到 Finance 数据库。"
+                )
+                or "结果制品未通过验收。",
+                details={**details, "failure_code": failure_code},
+            )
+
+
+def _submission_logs(
+    logs: List[Dict[str, Any]], submissions: Sequence[Mapping[str, Any]]
+) -> None:
+    for index, submission in enumerate(submissions, 1):
+        identity = submission.get("submission_key") or index
+        details = {
+            "submission_id": submission.get("submission_key"),
+            "attempt_no": submission.get("attempt_no"),
+            "submission_status": submission.get("submission_status"),
+            "processor_version": submission.get("processor_version"),
+            "business_date": submission.get("business_date"),
+            "record_count": submission.get("record_count"),
+            "daily_version_id": submission.get("daily_version_id"),
+        }
+        _append_log(
+            logs,
+            log_id=f"submission.{identity}.received",
+            timestamp=submission.get("created_at"),
+            stage="validation",
+            level="success",
+            code="DIRECT_RESULT_RECEIVED",
+            title="兼容结构化结果已接收",
+            message="ARR 已接收历史兼容链路提交的结构化结果。",
+            details=details,
+        )
+        if submission.get("validation_started_at"):
+            _append_log(
+                logs,
+                log_id=f"submission.{identity}.validating",
+                timestamp=submission.get("validation_started_at"),
+                stage="validation",
+                level="info",
+                code="DIRECT_VALIDATION_STARTED",
+                title="独立结果验收已开始",
+                message="ARR 正在重放确定性规则并比对业务事实。",
+                details=details,
+            )
+        status = str(submission.get("submission_status") or "")
+        if submission.get("finished_at"):
+            succeeded = status in {"committed", "already_committed"}
+            failure_code = submission.get("failure_code")
+            _append_log(
+                logs,
+                log_id=f"submission.{identity}.finished",
+                timestamp=submission.get("finished_at"),
+                stage="database" if succeeded else _failure_stage(failure_code),
+                level="success" if succeeded else "error",
+                code=str(
+                    "DIRECT_RESULT_ALREADY_COMMITTED"
+                    if status == "already_committed"
+                    else "DIRECT_RESULT_COMMITTED"
+                    if succeeded
+                    else failure_code or f"DIRECT_RESULT_{status.upper()}"
+                ),
+                title=(
+                    "相同结果已提交过"
+                    if status == "already_committed"
+                    else "结构化结果已提交数据库"
+                    if succeeded
+                    else "结构化结果未通过验收"
+                ),
+                message=(
+                    "已返回同一任务的既有数据库回执。"
+                    if status == "already_committed"
+                    else "验证通过的业务事实已原子提交到 Finance 数据库。"
+                    if succeeded
+                    else _safe_text(submission.get("failure_message"))
+                    or "结构化结果未通过 ARR 独立验收。"
+                ),
+                details={**details, "failure_code": failure_code},
+            )
+
+
+def _version_logs(logs: List[Dict[str, Any]], versions: Sequence[Mapping[str, Any]]) -> None:
+    for version in versions:
+        identity = version.get("daily_version_id")
+        details = {
+            "daily_version_id": identity,
+            "business_date": version.get("business_date"),
+            "version_no": version.get("version_no"),
+            "version_status": version.get("version_status"),
+            "delivery_mode": version.get("result_delivery_mode"),
+            "source_rows": version.get("source_rows"),
+            "retained_rows": version.get("retained_rows"),
+            "excluded_rate_code_rows": version.get("excluded_rate_code_rows"),
+            "duplicate_rows": version.get("duplicate_rows"),
+            "validation_failed_rows": version.get("validation_failed_rows"),
+            "price_unmatched_rows": version.get("price_unmatched_rows"),
+        }
+        _append_log(
+            logs,
+            log_id=f"version.{identity}.created",
+            timestamp=version.get("created_at"),
+            stage="database",
+            level="info",
+            code="FINANCE_VERSION_CREATED",
+            title="Finance 日报版本已创建",
+            message="业务结果已形成可追溯的日报版本。",
+            details=details,
+        )
+        if version.get("validated_at"):
+            failed = str(version.get("version_status") or "") == "rejected"
+            _append_log(
+                logs,
+                log_id=f"version.{identity}.validated",
+                timestamp=version.get("validated_at"),
+                stage="validation" if failed else "database",
+                level="error" if failed else "success",
+                code=str(
+                    version.get("failure_code")
+                    or ("FINANCE_VERSION_REJECTED" if failed else "FINANCE_VERSION_VALIDATED")
+                ),
+                title="Finance 版本已拒绝" if failed else "Finance 版本已验证",
+                message=(
+                    _safe_text(version.get("failure_message"))
+                    if failed
+                    else "行数对账、定价与版本约束已通过。"
+                )
+                or "Finance 版本未通过验证。",
+                details={**details, "failure_code": version.get("failure_code")},
+            )
+        if version.get("activated_at"):
+            _append_log(
+                logs,
+                log_id=f"version.{identity}.activated",
+                timestamp=version.get("activated_at"),
+                stage="database",
+                level="success",
+                code="FINANCE_VERSION_ACTIVATED",
+                title="Finance 当前版本已激活",
+                message="当前业务日期已指向本次提交的日报事实。",
+                details=details,
+            )
+
+
+def _outbox_logs(logs: List[Dict[str, Any]], events: Sequence[Mapping[str, Any]]) -> None:
+    for index, event in enumerate(events, 1):
+        identity = event.get("event_key") or index
+        event_type = str(event.get("event_type") or "")
+        is_failure = event_type == "arr.processing_failed"
+        details = {
+            "outbox_event": event_type,
+            "publish_status": event.get("publish_status"),
+            "publish_attempts": event.get("publish_attempts"),
+            "last_error_code": event.get("last_error_code"),
+        }
+        _append_log(
+            logs,
+            log_id=f"outbox.{identity}.created",
+            timestamp=event.get("created_at"),
+            stage="downstream",
+            level="warning" if is_failure else "info",
+            code="DOWNSTREAM_FAILURE_QUEUED" if is_failure else "DOWNSTREAM_EVENT_QUEUED",
+            title="失败通知已进入下游队列" if is_failure else "下游处理通知已入队",
+            message=(
+                "任务失败事实已写入可靠事件队列。"
+                if is_failure
+                else "数据库提交事件已写入可靠队列,等待下游消费者。"
+            ),
+            details=details,
+        )
+        status = str(event.get("publish_status") or "")
+        if event.get("published_at"):
+            _append_log(
+                logs,
+                log_id=f"outbox.{identity}.published",
+                timestamp=event.get("published_at"),
+                stage="downstream",
+                level="success",
+                code="DOWNSTREAM_EVENT_PUBLISHED",
+                title="下游处理通知已发布",
+                message="事件已交付给下游消费边界;这不等同于报表已生成。",
+                details=details,
+            )
+        elif status == "dead":
+            _append_log(
+                logs,
+                log_id=f"outbox.{identity}.dead",
+                timestamp=event.get("available_at") or event.get("created_at"),
+                stage="downstream",
+                level="error",
+                code=str(event.get("last_error_code") or "DOWNSTREAM_EVENT_DEAD"),
+                title="下游处理通知投递失败",
+                message="可靠事件已进入 dead 状态,需要检查消费者或投递器。",
+                details=details,
+            )
+        elif int(event.get("publish_attempts") or 0) > 0 and event.get("last_error_code"):
+            _append_log(
+                logs,
+                log_id=f"outbox.{identity}.retrying",
+                timestamp=event.get("available_at") or event.get("created_at"),
+                stage="downstream",
+                level="warning",
+                code=str(event.get("last_error_code")),
+                title="下游处理通知正在重试",
+                message="最近一次投递未完成,事件仍保留在可靠队列中。",
+                details=details,
+            )
+
+
+def build_job_trace(
+    run: Mapping[str, Any],
+    *,
+    attempts: Sequence[Mapping[str, Any]] = (),
+    deliveries: Sequence[Mapping[str, Any]] = (),
+    submissions: Sequence[Mapping[str, Any]] = (),
+    versions: Sequence[Mapping[str, Any]] = (),
+    outbox_events: Sequence[Mapping[str, Any]] = (),
+    refreshed_at: Optional[datetime] = None,
+) -> Dict[str, Any]:
+    """Build one complete trace using only explicitly allowlisted facts."""
+
+    logs: List[Dict[str, Any]] = []
+    _append_log(
+        logs,
+        log_id="run.received",
+        timestamp=run.get("created_at"),
+        stage="upload",
+        level="success",
+        code="UPLOAD_REGISTERED",
+        title="上传已安全登记",
+        message="XML 对象身份和任务身份已写入受控处理链路。",
+        details={
+            "job_id": run.get("job_id"),
+            "filename": run.get("filename"),
+            "source_byte_size": run.get("byte_size"),
+            "source_sha256": run.get("source_sha256"),
+        },
+    )
+    _attempt_logs(logs, attempts)
+    _delivery_logs(logs, deliveries)
+    _submission_logs(logs, submissions)
+    _version_logs(logs, versions)
+    _outbox_logs(logs, outbox_events)
+
+    run_status = str(run.get("run_status") or "")
+    public_status = str(run.get("public_status") or run_status)
+    failure_code = run.get("failure_code")
+    if run_status in {"failed", "rejected", "cancelled"} and run.get("finished_at"):
+        cancelled = run_status == "cancelled"
+        _append_log(
+            logs,
+            log_id=f"run.{run_status}",
+            timestamp=run.get("finished_at"),
+            stage=_failure_stage(failure_code),
+            level="warning" if cancelled else "error",
+            code=str(failure_code or f"JOB_{run_status.upper()}"),
+            title="任务已取消" if cancelled else "业务任务处理失败",
+            message=(
+                _safe_text(run.get("failure_message"))
+                or (
+                    "任务在收到可提交结果前被取消。"
+                    if cancelled
+                    else "任务未形成可接受的数据库提交。"
+                )
+            ),
+            details={
+                "job_id": run.get("job_id"),
+                "run_status": run_status,
+                "failure_code": failure_code,
+                "delivery_mode": run.get("delivery_mode"),
+            },
+        )
+    elif run_status == "accepted" and run.get("finished_at"):
+        _append_log(
+            logs,
+            log_id="run.accepted",
+            timestamp=run.get("finished_at"),
+            stage="database",
+            level="success",
+            code="JOB_SUCCEEDED",
+            title="业务任务已完成",
+            message="只有通过验收并提交 Finance 数据库后,任务才会进入此状态。",
+            details={
+                "job_id": run.get("job_id"),
+                "business_date": run.get("business_date"),
+                "delivery_mode": run.get("delivery_mode"),
+            },
+        )
+    elif run_status in ACTIVE_RUN_STATUSES:
+        title, message = _CURRENT_COPY[run_status]
+        _append_log(
+            logs,
+            log_id=f"run.current.{run_status}",
+            timestamp=run.get("updated_at") or run.get("created_at"),
+            stage=_CURRENT_STAGE[run_status],
+            level="info",
+            code=f"CURRENT_{run_status.upper()}",
+            title=title,
+            message=message,
+            details={
+                "job_id": run.get("job_id"),
+                "run_status": run_status,
+                "delivery_mode": run.get("delivery_mode"),
+            },
+        )
+
+    logs.sort(
+        key=lambda item: (
+            _timestamp_key(str(item["timestamp"])),
+            int(item["_order"]),
+            int(item["_sequence"]),
+        )
+    )
+    for item in logs:
+        item.pop("_order", None)
+        item.pop("_sequence", None)
+
+    latest_attempt = _latest(attempts, "attempt_no")
+    latest_version = _latest(versions, "version_no")
+    current_stage = (
+        _failure_stage(failure_code)
+        if failure_code
+        else _CURRENT_STAGE.get(run_status, logs[-1]["stage"] if logs else "upload")
+    )
+    if run_status == "accepted" and outbox_events:
+        current_stage = "downstream"
+    last_log_at = logs[-1]["timestamp"] if logs else _iso(run.get("updated_at"))
+    resolved_refreshed_at = refreshed_at or datetime.now(timezone.utc)
+
+    return {
+        "trace_version": "arr-job-trace-3",
+        "job": {
+            "job_id": str(run.get("job_id") or ""),
+            "status": public_status,
+            "raw_status": run_status,
+            "active": run_status in ACTIVE_RUN_STATUSES,
+            "current_stage": current_stage,
+            "filename": _safe_text(run.get("filename"), 255),
+            "byte_size": int(run.get("byte_size") or 0),
+            "source_sha256": _safe_text(run.get("source_sha256"), 64),
+            "business_date": _iso(run.get("business_date")),
+            "delivery_mode": _safe_text(run.get("delivery_mode"), 32),
+            "attempt_no": (
+                int(latest_attempt.get("attempt_no") or 0)
+                if latest_attempt is not None
+                else None
+            ),
+            "remote_run_id": (
+                _safe_text(latest_attempt.get("remote_run_id"), 128)
+                if latest_attempt is not None
+                else None
+            ),
+            "version_no": (
+                int(latest_version.get("version_no") or 0)
+                if latest_version is not None
+                and latest_version.get("version_no") is not None
+                else None
+            ),
+            "output_rows": (
+                int(latest_version.get("retained_rows") or 0)
+                if latest_version is not None
+                else 0
+            ),
+            "created_at": _iso(run.get("created_at")),
+            "updated_at": _iso(run.get("updated_at")),
+            "finished_at": _iso(run.get("finished_at")),
+            "last_log_at": last_log_at,
+            "failure": (
+                {
+                    "code": _safe_text(failure_code, 128),
+                    "message": _safe_text(run.get("failure_message")),
+                }
+                if failure_code
+                else None
+            ),
+            "requested_processor_version": _safe_text(
+                run.get("requested_processor_version"), 64
+            ),
+            "delivered_processor_version": _safe_text(
+                run.get("delivered_processor_version"), 64
+            ),
+        },
+        "evidence": {
+            "attempts": len(attempts),
+            "artifact_deliveries": len(deliveries),
+            "legacy_direct_submissions": len(submissions),
+            "finance_versions": len(versions),
+            "outbox_events": len(outbox_events),
+        },
+        "logs": logs,
+        "refreshed_at": resolved_refreshed_at.isoformat(),
+    }
diff --git a/arr_web/legacy_agent_services.py b/arr_web/legacy_agent_services.py
new file mode 100644
index 0000000..993522e
--- /dev/null
+++ b/arr_web/legacy_agent_services.py
@@ -0,0 +1,143 @@
+"""Inactive ARR1 compatibility adapters; never imported by the ARR2 Web runtime."""
+
+from __future__ import annotations
+
+import os
+import tempfile
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, Protocol
+
+from arr_ingestion.contracts import IngestionError
+from arr_ingestion.direct_contracts import SubmissionGrant
+from arr_ingestion.repository import JobRegistration
+from arr_processing.callbacks import AgentResultWriteback
+from arr_processing.contracts import ProcessingRequest
+from arr_processing.errors import ProcessingError
+from arr_processing.runner import processing_dispatch_idempotency_key
+from arr_storage.store import ManagedObjectStore
+from arr_web.contracts import PortalError, validate_upload_filename, validate_xml_payload
+
+
+class AgentResultCoordinator(Protocol):
+    def accept(self, raw_signed_result: bytes) -> Dict[str, Any]:
+        ...
+
+
+class ProcessingStarter(Protocol):
+    def start(self, request: ProcessingRequest) -> Any:
+        ...
+
+
+class DirectJobRepository(Protocol):
+    def register_job(self, registration: JobRegistration) -> None:
+        ...
+
+    def issue_grant(
+        self, job_id: str, attempt_no: int, *, ttl_seconds: int
+    ) -> SubmissionGrant:
+        ...
+
+
+@dataclass
+class ProcessingAgentResultCoordinator:
+    writeback: AgentResultWriteback
+
+    def accept(self, raw_signed_result: bytes) -> Dict[str, Any]:
+        try:
+            return self.writeback.accept(raw_signed_result).to_dict()
+        except (IngestionError, ProcessingError) as error:
+            code = error.code
+            if code in {"PROCESSING_SIGNATURE_INVALID", "PROCESSING_SIGNATURE_EXPIRED"}:
+                status, message = 401, "Agent 结果签名无效或已过期"
+            elif code.endswith("_NOT_FOUND") or code in {
+                "JOB_NOT_FOUND",
+                "PROCESSING_JOB_NOT_FOUND",
+            }:
+                status, message = 404, "Agent 结果对应的业务任务不存在"
+            elif "CONFLICT" in code or code in {
+                "JOB_TERMINAL",
+                "PROCESSING_RESULT_MISMATCH",
+            }:
+                status, message = 409, "Agent 结果与现有业务任务冲突"
+            elif getattr(error, "retryable", False) or code.startswith("DATABASE_"):
+                status, message = 503, "Agent 结果暂时无法写入,请使用相同回调重试"
+            else:
+                status, message = 422, "Agent 结果未通过业务验收"
+            raise PortalError(code, message, status) from None
+
+
+@dataclass
+class ObjectStoreUploadCoordinator:
+    """Frozen ARR1 upload/grant/remote-dispatch adapter for compatibility tests."""
+
+    object_store: ManagedObjectStore
+    ingestion_repository: DirectJobRepository
+    processing_starter: ProcessingStarter
+    processor_version: str
+    rule_set_sha256: str
+    submission_grant_ttl_seconds: int = 900
+
+    def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
+        uploaded_filename = validate_upload_filename(original_filename)
+        validate_xml_payload(payload)
+        job_id = "arrjob-" + uuid.uuid4().hex
+        attempt_no = 1
+        with tempfile.TemporaryDirectory(prefix="arr-web-upload-") as temporary:
+            source_path = Path(temporary) / "source.xml"
+            descriptor = os.open(source_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+            try:
+                with os.fdopen(descriptor, "wb") as target:
+                    descriptor = -1
+                    target.write(payload)
+                    target.flush()
+                    os.fsync(target.fileno())
+            finally:
+                if descriptor >= 0:
+                    os.close(descriptor)
+            stored = self.object_store.upload_committed(
+                job_id=job_id,
+                attempt_no=attempt_no,
+                role="source_xml",
+                source=source_path,
+                original_filename="source.xml",
+            )
+        source_file_id = "arr_file_" + uuid.uuid4().hex
+        request_without_grant = ProcessingRequest(
+            job_id=job_id,
+            attempt_no=attempt_no,
+            source_file_id=source_file_id,
+            processor_version=self.processor_version,
+            rule_set_sha256=self.rule_set_sha256,
+        )
+        self.ingestion_repository.register_job(
+            JobRegistration(
+                job_id=job_id,
+                source=stored.to_artifact_ref(),
+                processor_version=self.processor_version,
+                rule_set_sha256=self.rule_set_sha256,
+                attempt_no=attempt_no,
+                idempotency_key=processing_dispatch_idempotency_key(request_without_grant),
+                uploaded_filename=uploaded_filename,
+            )
+        )
+        grant = self.ingestion_repository.issue_grant(
+            job_id, attempt_no, ttl_seconds=self.submission_grant_ttl_seconds
+        )
+        request = ProcessingRequest(
+            job_id=job_id,
+            attempt_no=attempt_no,
+            source_file_id=source_file_id,
+            processor_version=self.processor_version,
+            rule_set_sha256=self.rule_set_sha256,
+            submission_grant=grant.submission_grant,
+        )
+        record = self.processing_starter.start(request)
+        return {
+            "job_id": job_id,
+            "status": str(getattr(record, "remote_status", "queued")),
+            "attempt_no": attempt_no,
+            "source_sha256": stored.sha256,
+            "source_byte_size": stored.byte_size,
+        }
diff --git a/arr_web/processing_runtime.py b/arr_web/processing_runtime.py
index d105385..53fdec4 100644
--- a/arr_web/processing_runtime.py
+++ b/arr_web/processing_runtime.py
@@ -1,4 +1,4 @@
-"""Production composition for XML upload and Open Agent dispatch."""
+"""Production composition for ARR-owned programmatic XML processing."""
 
 from __future__ import annotations
 
@@ -7,123 +7,69 @@ from dataclasses import dataclass
 from pathlib import Path
 from typing import Any, Callable, Optional
 
-from agent_integration import (
-    AgentConfig,
-    OpenAgentAPIClient,
-    OpenAgentService,
-    SQLiteSessionStore,
-)
-from arr_ingestion.postgres import DatabaseConfig
-from arr_ingestion.direct_postgres import PostgresDirectIngestionRepository
-from arr_processing.config import ResultVerificationConfig
-from arr_processing.open_agent import OpenAgentProcessingTransport
-from arr_processing.postgres import PostgresProcessingState, ProcessingDatabaseConfig
-from arr_processing.runner import ProcessingRunner
-from arr_processing.source_message import OssProcessingMessageBuilder
+from arr_ingestion.postgres import DatabaseConfig, PostgresIngestionRepository
+from arr_ingestion.service import IngestionService
+from arr_ingestion.validation import DeliveryValidator
+from arr_processing.local import LocalDailyProcessor
+from arr_processing.policy import load_processor_policy
 from arr_storage.aliyun_oss_v2 import AliyunOssConfig, AliyunOssV2Client
 from arr_storage.contracts import ObjectKeyPolicy
 from arr_storage.remote import CloudObjectBackend
 from arr_storage.store import ManagedObjectStore
-from arr_web.agent_writeback_runtime import load_processor_policy
-from arr_web.services import ObjectStoreUploadCoordinator
+from arr_web.programmatic import ProgrammaticUploadCoordinator
 
 
 @dataclass
 class ProcessingInputRuntime:
-    coordinator: ObjectStoreUploadCoordinator
-    api_client: OpenAgentAPIClient
-    session_store: SQLiteSessionStore
+    coordinator: ProgrammaticUploadCoordinator
     oss_client: AliyunOssV2Client
+    object_store: ManagedObjectStore
 
     def close(self) -> None:
-        errors = []
-        for close in (
-            self.api_client.close,
-            self.session_store.close,
-            self.oss_client.close,
-        ):
-            try:
-                close()
-            except Exception as error:  # pragma: no cover - best-effort shutdown
-                errors.append(error)
-        if errors:
-            raise errors[0]
+        self.oss_client.close()
 
 
-def compose_oss_processing_input(
+def compose_programmatic_processing(
     *,
     project_root: Path,
     connect: Optional[Callable[[str], Any]] = None,
 ) -> ProcessingInputRuntime:
-    oss_client: Optional[AliyunOssV2Client] = None
-    session_store: Optional[SQLiteSessionStore] = None
-    api_client: Optional[OpenAgentAPIClient] = None
+    oss_client = AliyunOssV2Client(AliyunOssConfig.from_environment())
     try:
-        agent_config = AgentConfig.from_env()
-        verification = ResultVerificationConfig.from_environment()
-        processor_policy = load_processor_policy(project_root)
-        oss_config = AliyunOssConfig.from_environment()
-        oss_client = AliyunOssV2Client(oss_config)
         oss_client.assert_immutable_writes_supported()
         object_store = ManagedObjectStore(
             CloudObjectBackend(oss_client),
             ObjectKeyPolicy(os.environ.get("ARR_OBJECT_PREFIX", "arr")),
         )
-        processing_config = (
-            ProcessingDatabaseConfig("controlled")
-            if connect is not None
-            else ProcessingDatabaseConfig.from_environment()
-        )
-        ingestion_config = (
+        database_config = (
             DatabaseConfig("controlled")
             if connect is not None
             else DatabaseConfig.from_environment()
         )
-        session_store = SQLiteSessionStore(agent_config.session_db)
-        if agent_config.api_key is None:
-            raise ValueError("Agent API key is unavailable")
-        api_client = OpenAgentAPIClient(
-            base_url=agent_config.base_url,
-            api_key=agent_config.api_key,
-            auth_mode=agent_config.auth_mode,
-            timeout=agent_config.timeout_seconds,
-            connect_timeout=agent_config.connect_timeout_seconds,
+        repository = PostgresIngestionRepository(
+            database_config,
+            connect=connect,
         )
-        service = OpenAgentService(api_client, session_store)
-        processing_state = PostgresProcessingState(processing_config, connect=connect)
-        runner = ProcessingRunner(
-            OpenAgentProcessingTransport(
-                service,
-                message_builder=OssProcessingMessageBuilder(
-                    oss_config,
-                    processing_state,
-                ).message,
-            ),
-            processing_state,
-            verification.verifier(),
-        )
-        coordinator = ObjectStoreUploadCoordinator(
+        policy = load_processor_policy(project_root)
+        coordinator = ProgrammaticUploadCoordinator(
             object_store=object_store,
-            ingestion_repository=PostgresDirectIngestionRepository(
-                ingestion_config,
-                connect=connect,
+            ingestion_repository=repository,
+            ingestion_service=IngestionService(
+                DeliveryValidator(object_store, policy),
+                repository,
             ),
-            processing_starter=runner,
-            processor_version=processor_policy.processor_version,
-            rule_set_sha256=processor_policy.rule_set_sha256,
-        )
-        return ProcessingInputRuntime(
-            coordinator,
-            api_client,
-            session_store,
-            oss_client,
+            processor=LocalDailyProcessor(policy),
+            processor_version=policy.processor_version,
+            rule_set_sha256=policy.rule_set_sha256,
         )
+        return ProcessingInputRuntime(coordinator, oss_client, object_store)
     except Exception:
-        for resource in (api_client, session_store, oss_client):
-            if resource is None:
-                continue
-            try:
-                resource.close()
-            except Exception:
-                pass
+        try:
+            oss_client.close()
+        except Exception:
+            pass
         raise
+
+
+# Compatibility name for older local launch wrappers.
+compose_oss_processing_input = compose_programmatic_processing
diff --git a/arr_web/programmatic.py b/arr_web/programmatic.py
new file mode 100644
index 0000000..75f8c31
--- /dev/null
+++ b/arr_web/programmatic.py
@@ -0,0 +1,236 @@
+"""Programmatic XML upload, processing, validation, and ingestion boundary."""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import tempfile
+import uuid
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict
+
+from arr_ingestion.contracts import (
+    ARTIFACT_ROLES,
+    DELIVERY_SCHEMA_VERSION,
+    RESULT_SCHEMA_VERSION,
+    DeliveryEnvelope,
+    IngestionError,
+)
+from arr_ingestion.repository import IngestionRepository, JobRegistration
+from arr_ingestion.service import IngestionService
+from arr_processing.local import LocalDailyProcessor
+from arr_storage.store import ManagedObjectStore, StoredObject
+from arr_web.contracts import PortalError, validate_upload_filename, validate_xml_payload
+
+
+@dataclass
+class ProgrammaticUploadCoordinator:
+    """Own the complete upload-to-terminal transaction without Agent or MCP."""
+
+    object_store: ManagedObjectStore
+    ingestion_repository: IngestionRepository
+    ingestion_service: IngestionService
+    processor: LocalDailyProcessor
+    processor_version: str
+    rule_set_sha256: str
+
+    def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
+        uploaded_filename = validate_upload_filename(original_filename)
+        validate_xml_payload(payload)
+        job_id = "arrjob-" + uuid.uuid4().hex
+        attempt_no = 1
+        registered = False
+        terminal = False
+        try:
+            with tempfile.TemporaryDirectory(prefix="arr-programmatic-") as temporary:
+                root = Path(temporary)
+                upload_path = root / "upload" / "source.xml"
+                self._write_private(upload_path, payload)
+                source = self.object_store.upload_committed(
+                    job_id=job_id,
+                    attempt_no=attempt_no,
+                    role="source_xml",
+                    source=upload_path,
+                    original_filename="source.xml",
+                )
+                self.ingestion_repository.register_job(
+                    JobRegistration(
+                        job_id=job_id,
+                        source=source.to_artifact_ref(),
+                        processor_version=self.processor_version,
+                        rule_set_sha256=self.rule_set_sha256,
+                        attempt_no=attempt_no,
+                        idempotency_key=self._idempotency_key(
+                            job_id, attempt_no, source
+                        ),
+                        uploaded_filename=uploaded_filename,
+                    )
+                )
+                registered = True
+                self.ingestion_repository.mark_running(job_id, attempt_no)
+
+                materialized_source = root / "processor-input" / "source.xml"
+                self.object_store.materialize(
+                    source.object_key,
+                    materialized_source,
+                    source.byte_size,
+                )
+                processed = self.processor.run(
+                    materialized_source,
+                    root / "processor-output",
+                )
+                stored_outputs: Dict[str, StoredObject] = {}
+                for role, path in processed.artifacts.items():
+                    stored_outputs[role] = self.object_store.upload_committed(
+                        job_id=job_id,
+                        attempt_no=attempt_no,
+                        role=role,
+                        source=path,
+                        original_filename=path.name,
+                    )
+                envelope = self._delivery(
+                    job_id=job_id,
+                    attempt_no=attempt_no,
+                    source=source,
+                    outputs=stored_outputs,
+                    status=processed.status,
+                    business_date=(
+                        processed.business_date.isoformat()
+                        if processed.business_date is not None
+                        else None
+                    ),
+                )
+                raw_envelope = (
+                    json.dumps(
+                        envelope.to_dict(),
+                        ensure_ascii=False,
+                        allow_nan=False,
+                        sort_keys=True,
+                        separators=(",", ":"),
+                    )
+                    + "\n"
+                ).encode("utf-8")
+                outcome = self.ingestion_service.ingest(raw_envelope)
+                terminal = True
+            return {
+                "job_id": job_id,
+                "status": (
+                    "failed"
+                    if outcome.status == "recorded_failure"
+                    else "succeeded"
+                ),
+                "ingestion_status": outcome.status,
+                "attempt_no": attempt_no,
+                "business_date": (
+                    outcome.business_date.isoformat()
+                    if outcome.business_date is not None
+                    else None
+                ),
+                "daily_version_id": outcome.daily_version_id,
+                "version_no": outcome.version_no,
+                "source_sha256": source.sha256,
+                "source_byte_size": source.byte_size,
+            }
+        except PortalError:
+            raise
+        except IngestionError as error:
+            if registered and not terminal:
+                self._best_effort_failure(job_id, attempt_no, error.code)
+            retryable = (
+                error.retryable
+                or error.code.startswith("DATABASE_")
+                or error.code in {"OBJECT_STORE_UNAVAILABLE", "PROCESSOR_UNAVAILABLE"}
+            )
+            raise PortalError(
+                error.code,
+                error.safe_message,
+                503 if retryable else 422,
+            ) from None
+        except Exception:
+            if registered and not terminal:
+                self._best_effort_failure(
+                    job_id, attempt_no, "PROGRAMMATIC_PROCESSING_FAILED"
+                )
+            raise PortalError(
+                "PROGRAMMATIC_PROCESSING_FAILED",
+                "文件已安全接收,但程序化处理未能完成",
+                503,
+            ) from None
+
+    def _best_effort_failure(
+        self,
+        job_id: str,
+        attempt_no: int,
+        failure_code: str,
+    ) -> None:
+        try:
+            self.ingestion_repository.record_failure(
+                job_id, attempt_no, failure_code
+            )
+        except Exception:
+            pass
+
+    def _delivery(
+        self,
+        *,
+        job_id: str,
+        attempt_no: int,
+        source: StoredObject,
+        outputs: Dict[str, StoredObject],
+        status: str,
+        business_date: object,
+    ) -> DeliveryEnvelope:
+        artifacts: Dict[str, object] = {
+            role: None for role in ARTIFACT_ROLES
+        }
+        artifacts["source_xml"] = source.to_artifact_ref().to_dict()
+        for role, stored in outputs.items():
+            artifacts[role] = stored.to_artifact_ref().to_dict()
+        return DeliveryEnvelope.from_dict(
+            {
+                "delivery_schema_version": DELIVERY_SCHEMA_VERSION,
+                "delivery_id": f"local-{job_id}-a{attempt_no}",
+                "job_id": job_id,
+                "attempt_no": attempt_no,
+                "status": status,
+                "processor_version": self.processor_version,
+                "rule_set_sha256": self.rule_set_sha256,
+                "result_schema_version": RESULT_SCHEMA_VERSION,
+                "business_date": business_date,
+                "artifacts": artifacts,
+            }
+        )
+
+    def _idempotency_key(
+        self,
+        job_id: str,
+        attempt_no: int,
+        source: StoredObject,
+    ) -> str:
+        value = "\x00".join(
+            (
+                "arr-programmatic-v1",
+                job_id,
+                str(attempt_no),
+                source.sha256,
+                self.processor_version,
+                self.rule_set_sha256,
+            )
+        ).encode("utf-8")
+        return hashlib.sha256(value).hexdigest()
+
+    @staticmethod
+    def _write_private(path: Path, payload: bytes) -> None:
+        path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
+        descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
+        try:
+            with os.fdopen(descriptor, "wb") as target:
+                descriptor = -1
+                target.write(payload)
+                target.flush()
+                os.fsync(target.fileno())
+        finally:
+            if descriptor >= 0:
+                os.close(descriptor)
diff --git a/arr_web/repository.py b/arr_web/repository.py
index a0b9f41..3c2cf53 100644
--- a/arr_web/repository.py
+++ b/arr_web/repository.py
@@ -13,6 +13,7 @@ from channel_analytics.postgres import (
     PostgresAnalyticsRepository,
 )
 from arr_web.downloads import ArtifactDescriptor
+from arr_web.job_trace import build_job_trace
 
 
 TARGET_DATABASE = "booking_test"
@@ -26,10 +27,23 @@ class PortalDataError(RuntimeError):
 
 
 class PortalRepository(Protocol):
-    def list_jobs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]:
+    def list_jobs(
+        self,
+        month_key: str,
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         ...
 
-    def list_monthly_runs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]:
+    def get_job_trace(self, job_id: str) -> Dict[str, Any]:
+        ...
+
+    def list_monthly_runs(
+        self,
+        month_key: str,
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         ...
 
     def list_months(self) -> List[Dict[str, Any]]:
@@ -61,10 +75,23 @@ class UnavailablePortalRepository:
     def _raise() -> None:
         raise PortalDataError("DATABASE_UNAVAILABLE", "数据库读取服务暂不可用")
 
-    def list_jobs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]:
+    def list_jobs(
+        self,
+        month_key: str,
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         self._raise()
 
-    def list_monthly_runs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]:
+    def get_job_trace(self, job_id: str) -> Dict[str, Any]:
+        self._raise()
+
+    def list_monthly_runs(
+        self,
+        month_key: str,
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         self._raise()
 
     def list_months(self) -> List[Dict[str, Any]]:
@@ -126,7 +153,7 @@ SELECT
     run.created_at,
     run.updated_at,
     run.finished_at,
-    source.original_filename,
+    run.uploaded_filename,
     source.byte_size,
     version.retained_rows,
     version.version_no,
@@ -155,43 +182,195 @@ WHERE run.pipeline_type = 'opera_daily'
         )
       )
 ORDER BY run.created_at DESC, run.id DESC
-LIMIT %s
+LIMIT %s OFFSET %s
+""".strip()
+
+
+JOBS_COUNT_SQL = """
+SELECT count(*)
+FROM ingestion.processing_runs AS run
+WHERE run.pipeline_type = 'opera_daily'
+  AND (
+        (run.business_date >= %s AND run.business_date < %s)
+        OR (
+            run.business_date IS NULL
+            AND run.created_at >= %s
+            AND run.created_at < %s
+        )
+      )
+""".strip()
+
+
+JOB_TRACE_RUN_SQL = """
+SELECT
+    run.id,
+    run.run_key,
+    run.run_status,
+    CASE run.run_status
+        WHEN 'accepted' THEN 'succeeded'
+        WHEN 'rejected' THEN 'failed'
+        ELSE run.run_status
+    END AS public_status,
+    run.result_delivery_mode,
+    run.business_date,
+    run.failure_code,
+    run.failure_message,
+    run.created_at,
+    run.updated_at,
+    run.validated_at,
+    run.finished_at,
+    run.uploaded_filename,
+    source.byte_size,
+    source.sha256,
+    run.requested_processor_version,
+    run.delivered_processor_version,
+    run.requested_rule_set_sha256,
+    run.delivered_rule_set_sha256,
+    run.result_schema_version
+FROM ingestion.processing_runs AS run
+JOIN ingestion.artifacts AS source
+  ON source.id = run.source_artifact_id
+WHERE run.pipeline_type = 'opera_daily'
+  AND run.run_key = %s
+""".strip()
+
+
+JOB_TRACE_ATTEMPTS_SQL = """
+SELECT
+    attempt.id,
+    attempt.attempt_no,
+    attempt.attempt_status,
+    attempt.remote_run_id,
+    attempt.failure_code,
+    attempt.failure_message,
+    attempt.created_at,
+    attempt.started_at,
+    attempt.finished_at
+FROM ingestion.processing_attempts AS attempt
+WHERE attempt.processing_run_id = %s
+ORDER BY attempt.attempt_no, attempt.id
+""".strip()
+
+
+JOB_TRACE_DELIVERIES_SQL = """
+SELECT
+    delivery.delivery_key,
+    attempt.attempt_no,
+    delivery.delivery_status,
+    delivery.result_status,
+    delivery.processor_version,
+    delivery.result_schema_version,
+    delivery.business_date,
+    delivery.daily_version_id,
+    delivery.failure_code,
+    delivery.failure_message,
+    delivery.received_at,
+    delivery.validated_at,
+    delivery.committed_at
+FROM ingestion.processing_deliveries AS delivery
+LEFT JOIN ingestion.processing_attempts AS attempt
+  ON attempt.id = delivery.attempt_id
+ AND attempt.processing_run_id = delivery.processing_run_id
+WHERE delivery.processing_run_id = %s
+ORDER BY delivery.received_at, delivery.id
+""".strip()
+
+
+JOB_TRACE_SUBMISSIONS_SQL = """
+SELECT
+    submission.submission_key,
+    attempt.attempt_no,
+    submission.submission_status,
+    submission.contract_version,
+    submission.business_date,
+    submission.processor_version,
+    submission.result_schema_version,
+    submission.record_count,
+    submission.daily_version_id,
+    submission.failure_code,
+    submission.failure_message,
+    submission.created_at,
+    submission.validation_started_at,
+    submission.finished_at
+FROM ingestion.result_submissions AS submission
+JOIN ingestion.processing_attempts AS attempt
+  ON attempt.id = submission.attempt_id
+ AND attempt.processing_run_id = submission.processing_run_id
+WHERE submission.processing_run_id = %s
+ORDER BY submission.created_at, submission.id
+""".strip()
+
+
+JOB_TRACE_VERSIONS_SQL = """
+SELECT
+    version.id,
+    version.business_date,
+    version.version_no,
+    version.version_status,
+    version.result_delivery_mode,
+    version.source_rows,
+    version.retained_rows,
+    version.excluded_rate_code_rows,
+    version.duplicate_rows,
+    version.validation_failed_rows,
+    version.price_unmatched_rows,
+    version.failure_code,
+    version.failure_message,
+    version.created_at,
+    version.validated_at,
+    version.activated_at,
+    version.superseded_at
+FROM finance.daily_versions AS version
+WHERE version.processing_run_id = %s
+ORDER BY COALESCE(version.version_no, 0), version.id
+""".strip()
+
+
+JOB_TRACE_OUTBOX_SQL = """
+SELECT
+    event.event_key,
+    event.event_type,
+    event.publish_status,
+    event.publish_attempts,
+    event.available_at,
+    event.created_at,
+    event.published_at,
+    event.last_error_code
+FROM ingestion.outbox_events AS event
+WHERE event.aggregate_type = 'processing_run'
+  AND event.aggregate_id = %s
+ORDER BY event.created_at, event.id
 """.strip()
 
 
 MONTHLY_RUNS_SQL = """
-WITH selected_versions AS (
-    SELECT
-        current_version.business_date,
-        current_version.daily_version_id,
-        current_version.activated_at
-    FROM finance.current_daily_versions AS current_version
-    WHERE current_version.business_date >= %s
-      AND current_version.business_date < %s
-),
-selected_facts AS (
-    SELECT fact.id, fact.channel_key
-    FROM finance.v_active_daily_facts AS fact
-    WHERE fact.business_date >= %s
-      AND fact.business_date < %s
-)
 SELECT
-    NULL::bigint AS report_id,
-    min(selected_versions.business_date)::date AS period_start,
-    max(selected_versions.business_date)::date AS as_of_date,
-    1::integer AS version_no,
-    'source_ready'::text AS report_status,
-    true AS is_current,
-    min(selected_versions.activated_at) AS created_at,
-    max(selected_versions.activated_at) AS updated_at,
-    NULL::text AS failure_code,
-    NULL::text AS original_filename,
-    NULL::text AS artifact_sha256,
-    (SELECT count(DISTINCT channel_key) FROM selected_facts) AS channel_count,
-    (SELECT count(*) FROM selected_facts) AS row_count
-FROM selected_versions
-HAVING count(*) > 0
-LIMIT %s
+    run.id AS report_id,
+    run.period_start,
+    run.as_of_date AS max_arrival_date,
+    run.version_no,
+    run.report_status,
+    run.report_status = 'active' AS is_current,
+    run.created_at,
+    run.updated_at,
+    run.failure_code,
+    artifact.original_filename,
+    artifact.sha256,
+    run.channel_count,
+    run.row_count
+FROM reporting.monthly_runs AS run
+LEFT JOIN ingestion.artifacts AS artifact
+  ON artifact.id = run.workbook_artifact_id
+WHERE run.period_start = %s
+ORDER BY run.version_no DESC, run.id DESC
+LIMIT %s OFFSET %s
+""".strip()
+
+
+MONTHLY_RUNS_COUNT_SQL = """
+SELECT count(*)
+FROM reporting.monthly_runs AS run
+WHERE run.period_start = %s
 """.strip()
 
 
@@ -213,6 +392,23 @@ WHERE run.run_key = %s
   AND version.version_status IN ('active', 'superseded')
 """.strip()
 
+MONTHLY_DOWNLOAD_SQL = """
+SELECT
+    artifact.artifact_kind,
+    artifact.original_filename,
+    artifact.object_key,
+    artifact.sha256,
+    artifact.byte_size,
+    artifact.mime_type
+FROM reporting.monthly_runs AS run
+JOIN ingestion.artifacts AS artifact
+  ON artifact.id = run.workbook_artifact_id
+WHERE run.id = %s
+  AND run.report_status IN ('active', 'superseded')
+  AND artifact.artifact_kind = 'monthly_xlsx'
+  AND artifact.storage_provider = 'local'
+""".strip()
+
 
 @dataclass(frozen=True)
 class PostgresPortalRepository:
@@ -266,14 +462,25 @@ class PostgresPortalRepository:
         end = date(year + (month == 12), 1 if month == 12 else month + 1, 1)
         return start, end
 
-    def list_jobs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]:
+    def list_jobs(
+        self,
+        month_key: str,
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         start, end = self._month_bounds(month_key)
         connection = self._open()
         try:
             with connection.transaction():
                 with connection.cursor() as cursor:
                     self._begin(cursor)
-                    cursor.execute(JOBS_SQL, (start, end, start, end, limit))
+                    cursor.execute(JOBS_COUNT_SQL, (start, end, start, end))
+                    count_row = cursor.fetchone()
+                    total = int(count_row[0] or 0) if count_row else 0
+                    cursor.execute(
+                        JOBS_SQL,
+                        (start, end, start, end, limit, offset),
+                    )
                     rows = list(cursor.fetchall())
             return [
                 {
@@ -284,7 +491,7 @@ class PostgresPortalRepository:
                     "created_at": _iso(row[4]),
                     "updated_at": _iso(row[5]),
                     "finished_at": _iso(row[6]),
-                    "filename": str(row[7]),
+                    "filename": str(row[7]) if row[7] else None,
                     "byte_size": int(row[8]),
                     "output_rows": int(row[9] or 0),
                     "version_no": int(row[10]) if row[10] is not None else None,
@@ -292,7 +499,7 @@ class PostgresPortalRepository:
                     "no_of_rooms": int(row[12] or 0),
                 }
                 for row in rows
-            ]
+            ], total
         except PortalDataError:
             raise
         except Exception:
@@ -300,20 +507,186 @@ class PostgresPortalRepository:
         finally:
             connection.close()
 
-    def list_monthly_runs(self, month_key: str, limit: int = 100) -> List[Dict[str, Any]]:
+    def get_job_trace(self, job_id: str) -> Dict[str, Any]:
+        connection = self._open()
+        try:
+            with connection.transaction():
+                with connection.cursor() as cursor:
+                    self._begin(cursor)
+                    cursor.execute(JOB_TRACE_RUN_SQL, (job_id,))
+                    row = cursor.fetchone()
+                    if row is None:
+                        raise PortalDataError("JOB_NOT_FOUND", "任务不存在或已过保留期")
+                    run = {
+                        "id": int(row[0]),
+                        "job_id": str(row[1]),
+                        "run_status": str(row[2]),
+                        "public_status": str(row[3]),
+                        "delivery_mode": str(row[4]),
+                        "business_date": row[5],
+                        "failure_code": str(row[6]) if row[6] else None,
+                        "failure_message": str(row[7]) if row[7] else None,
+                        "created_at": row[8],
+                        "updated_at": row[9],
+                        "validated_at": row[10],
+                        "finished_at": row[11],
+                        "filename": str(row[12]) if row[12] else None,
+                        "byte_size": int(row[13]),
+                        "source_sha256": str(row[14]),
+                        "requested_processor_version": (
+                            str(row[15]) if row[15] else None
+                        ),
+                        "delivered_processor_version": (
+                            str(row[16]) if row[16] else None
+                        ),
+                        "requested_rule_set_sha256": (
+                            str(row[17]) if row[17] else None
+                        ),
+                        "delivered_rule_set_sha256": (
+                            str(row[18]) if row[18] else None
+                        ),
+                        "result_schema_version": str(row[19]) if row[19] else None,
+                    }
+                    run_id = int(row[0])
+
+                    cursor.execute(JOB_TRACE_ATTEMPTS_SQL, (run_id,))
+                    attempts = [
+                        {
+                            "attempt_id": int(item[0]),
+                            "attempt_no": int(item[1]),
+                            "attempt_status": str(item[2]),
+                            "remote_run_id": str(item[3]) if item[3] else None,
+                            "failure_code": str(item[4]) if item[4] else None,
+                            "failure_message": str(item[5]) if item[5] else None,
+                            "created_at": item[6],
+                            "started_at": item[7],
+                            "finished_at": item[8],
+                        }
+                        for item in cursor.fetchall()
+                    ]
+
+                    cursor.execute(JOB_TRACE_DELIVERIES_SQL, (run_id,))
+                    deliveries = [
+                        {
+                            "delivery_key": str(item[0]),
+                            "attempt_no": int(item[1]) if item[1] is not None else None,
+                            "delivery_status": str(item[2]),
+                            "result_status": str(item[3]),
+                            "processor_version": str(item[4]),
+                            "result_schema_version": str(item[5]),
+                            "business_date": item[6],
+                            "daily_version_id": (
+                                int(item[7]) if item[7] is not None else None
+                            ),
+                            "failure_code": str(item[8]) if item[8] else None,
+                            "failure_message": str(item[9]) if item[9] else None,
+                            "received_at": item[10],
+                            "validated_at": item[11],
+                            "committed_at": item[12],
+                        }
+                        for item in cursor.fetchall()
+                    ]
+
+                    cursor.execute(JOB_TRACE_SUBMISSIONS_SQL, (run_id,))
+                    submissions = [
+                        {
+                            "submission_key": str(item[0]),
+                            "attempt_no": int(item[1]),
+                            "submission_status": str(item[2]),
+                            "contract_version": str(item[3]),
+                            "business_date": item[4],
+                            "processor_version": str(item[5]),
+                            "result_schema_version": str(item[6]),
+                            "record_count": int(item[7]),
+                            "daily_version_id": (
+                                int(item[8]) if item[8] is not None else None
+                            ),
+                            "failure_code": str(item[9]) if item[9] else None,
+                            "failure_message": str(item[10]) if item[10] else None,
+                            "created_at": item[11],
+                            "validation_started_at": item[12],
+                            "finished_at": item[13],
+                        }
+                        for item in cursor.fetchall()
+                    ]
+
+                    cursor.execute(JOB_TRACE_VERSIONS_SQL, (run_id,))
+                    versions = [
+                        {
+                            "daily_version_id": int(item[0]),
+                            "business_date": item[1],
+                            "version_no": (
+                                int(item[2]) if item[2] is not None else None
+                            ),
+                            "version_status": str(item[3]),
+                            "result_delivery_mode": str(item[4]),
+                            "source_rows": int(item[5]),
+                            "retained_rows": int(item[6]),
+                            "excluded_rate_code_rows": int(item[7]),
+                            "duplicate_rows": int(item[8]),
+                            "validation_failed_rows": int(item[9]),
+                            "price_unmatched_rows": int(item[10]),
+                            "failure_code": str(item[11]) if item[11] else None,
+                            "failure_message": str(item[12]) if item[12] else None,
+                            "created_at": item[13],
+                            "validated_at": item[14],
+                            "activated_at": item[15],
+                            "superseded_at": item[16],
+                        }
+                        for item in cursor.fetchall()
+                    ]
+
+                    cursor.execute(JOB_TRACE_OUTBOX_SQL, (run_id,))
+                    outbox_events = [
+                        {
+                            "event_key": str(item[0]),
+                            "event_type": str(item[1]),
+                            "publish_status": str(item[2]),
+                            "publish_attempts": int(item[3]),
+                            "available_at": item[4],
+                            "created_at": item[5],
+                            "published_at": item[6],
+                            "last_error_code": str(item[7]) if item[7] else None,
+                        }
+                        for item in cursor.fetchall()
+                    ]
+            return build_job_trace(
+                run,
+                attempts=attempts,
+                deliveries=deliveries,
+                submissions=submissions,
+                versions=versions,
+                outbox_events=outbox_events,
+            )
+        except PortalDataError:
+            raise
+        except Exception:
+            raise PortalDataError("DATABASE_QUERY_FAILED", "任务日志查询失败") from None
+        finally:
+            connection.close()
+
+    def list_monthly_runs(
+        self,
+        month_key: str,
+        limit: int = 50,
+        offset: int = 0,
+    ) -> tuple[List[Dict[str, Any]], int]:
         start, _end = self._month_bounds(month_key)
         connection = self._open()
         try:
             with connection.transaction():
                 with connection.cursor() as cursor:
                     self._begin(cursor)
-                    _start, end = self._month_bounds(month_key)
-                    cursor.execute(MONTHLY_RUNS_SQL, (start, end, start, end, limit))
+                    cursor.execute(MONTHLY_RUNS_COUNT_SQL, (start,))
+                    count_row = cursor.fetchone()
+                    total = int(count_row[0] or 0) if count_row else 0
+                    cursor.execute(MONTHLY_RUNS_SQL, (start, limit, offset))
                     rows = list(cursor.fetchall())
             return [
                 {
                     "report_id": int(row[0]) if row[0] is not None else None,
                     "month_key": start.strftime("%Y-%m"),
+                    "max_arrival_date": _iso(row[2]),
                     "as_of_date": _iso(row[2]),
                     "version_no": int(row[3]),
                     "status": str(row[4]),
@@ -327,7 +700,7 @@ class PostgresPortalRepository:
                     "row_count": int(row[12]),
                 }
                 for row in rows
-            ]
+            ], total
         except PortalDataError:
             raise
         except Exception:
@@ -406,7 +779,4 @@ class PostgresPortalRepository:
     def resolve_monthly_download(self, report_id: int) -> ArtifactDescriptor:
         if isinstance(report_id, bool) or not isinstance(report_id, int) or report_id < 1:
             raise PortalDataError("DOWNLOAD_REQUEST_INVALID", "下载请求无效")
-        raise PortalDataError(
-            "DOWNLOAD_NOT_FOUND",
-            "月报行与版本不在数据库重复保存;请从本次生成结果下载",
-        )
+        return self._resolve_download(MONTHLY_DOWNLOAD_SQL, report_id)
diff --git a/arr_web/run.py b/arr_web/run.py
index 3346342..b7b86d9 100644
--- a/arr_web/run.py
+++ b/arr_web/run.py
@@ -3,24 +3,26 @@
 from __future__ import annotations
 
 import argparse
-import sys
 from pathlib import Path
-from typing import Any, Callable, Dict, Optional, Sequence
+from typing import Any, Callable, Optional, Sequence
 
+from arr_database import controlled_connect
 from arr_web.app import PortalApplication, RuntimeHealth
-from arr_web.agent_writeback_runtime import (
-    AgentWritebackRuntime,
-    compose_oss_agent_writeback,
-)
+from arr_web.auth import LoginCredentials
+from arr_web.booking_uploads import ProgramBookingSourceCoordinator
 from arr_web.company_jobs import (
     PersistentCompanyReportCoordinator,
     ProgramCompanyReportExecutor,
 )
-from arr_web.downloads import ControlledProjectArtifactReader
+from arr_web.downloads import (
+    ControlledProjectArtifactReader,
+    ManagedObjectArtifactReader,
+    RoutedArtifactReader,
+)
 from arr_web.repository import PostgresPortalRepository, UnavailablePortalRepository
 from arr_web.processing_runtime import (
     ProcessingInputRuntime,
-    compose_oss_processing_input,
+    compose_programmatic_processing,
 )
 from arr_web.server import serve
 from arr_web.services import ProgramMonthlyCoordinator
@@ -36,6 +38,10 @@ from company_reports.repository import (
     PostgresReportRepository as PostgresCompanyReportRepository,
 )
 from company_reports.service import CompanyReportService
+from booking_ingestion.excel_postgres import (
+    DatabaseConfig as BookingExcelDatabaseConfig,
+)
+from booking_ingestion.excel_review_postgres import PostgresBookingReviewRepository
 
 
 PROJECT_ROOT = Path(__file__).resolve().parents[1]
@@ -65,15 +71,10 @@ def _parser() -> argparse.ArgumentParser:
         action="store_true",
         help="enable Bangkok-gated five-company channel-detail jobs",
     )
-    parser.add_argument(
-        "--enable-agent-writeback",
-        action="store_true",
-        help="enable signed Super Agent result callbacks backed by private OSS",
-    )
     parser.add_argument(
         "--enable-processing",
         action="store_true",
-        help="enable private OSS upload and Open Agent dispatch",
+        help="enable ARR-owned deterministic XML processing and ingestion",
     )
     parser.add_argument(
         "--secure-cookies",
@@ -85,52 +86,20 @@ def _parser() -> argparse.ArgumentParser:
     return parser
 
 
-def _read_controlled_database(path: Path) -> Dict[str, object]:
-    values: Dict[str, str] = {}
-    for raw in path.read_text(encoding="utf-8").splitlines():
-        line = raw.strip()
-        if not line or line.startswith("#") or "=" not in line:
-            continue
-        key, value = line.split("=", 1)
-        values[key.strip()] = value.strip().strip('"').strip("'")
-    required = ("ARR_DB_HOST", "ARR_DB_PORT", "ARR_DB_USER", "ARR_DB_PASSWORD", "ARR_DB_NAME")
-    if any(not values.get(key) for key in required):
-        raise ValueError("controlled database configuration is incomplete")
-    return {
-        "host": values["ARR_DB_HOST"],
-        "port": int(values["ARR_DB_PORT"]),
-        "user": values["ARR_DB_USER"],
-        "password": values["ARR_DB_PASSWORD"],
-        "dbname": values["ARR_DB_NAME"],
-    }
-
-
-def _controlled_connect(
-    config_path: Path,
-    driver_path: Optional[Path],
-) -> Callable[[str], Any]:
-    if driver_path is not None:
-        resolved = driver_path.expanduser().resolve()
-        if not resolved.is_dir():
-            raise ValueError("database driver path is unavailable")
-        sys.path.insert(0, str(resolved))
-    import psycopg  # type: ignore[import-not-found]
-
-    parameters = _read_controlled_database(config_path.expanduser().resolve())
-
-    def connect(_dsn: str) -> Any:
-        return psycopg.connect(**parameters, autocommit=False)
-
-    return connect
-
-
 def main(argv: Optional[Sequence[str]] = None) -> int:
     args = _parser().parse_args(argv)
+    try:
+        login_credentials = LoginCredentials.from_environment()
+    except ValueError as error:
+        raise SystemExit(str(error)) from None
     connect: Optional[Callable[[str], Any]] = None
     try:
         if args.db_config:
-            connect = _controlled_connect(args.db_config, args.driver_path)
-            repository = PostgresPortalRepository("controlled", connect=connect)
+            connect = controlled_connect(args.db_config, args.driver_path)
+            repository = PostgresPortalRepository(
+                "controlled",
+                connect=connect,
+            )
         else:
             repository = PostgresPortalRepository.from_environment()
         repository.list_months()
@@ -200,23 +169,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         except Exception:
             company_reports = None
             company_reports_ready = False
-    agent_writeback: Optional[AgentWritebackRuntime] = None
-    agent_writeback_ready = False
-    if args.enable_agent_writeback and database_ready:
-        try:
-            agent_writeback = compose_oss_agent_writeback(
-                project_root=PROJECT_ROOT,
-                connect=connect,
-            )
-            agent_writeback_ready = True
-        except Exception:
-            agent_writeback = None
-            agent_writeback_ready = False
     processing_input: Optional[ProcessingInputRuntime] = None
     processing_ready = False
     if args.enable_processing and database_ready:
         try:
-            processing_input = compose_oss_processing_input(
+            processing_input = compose_programmatic_processing(
                 project_root=PROJECT_ROOT,
                 connect=connect,
             )
@@ -224,18 +181,45 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
         except Exception:
             processing_input = None
             processing_ready = False
+    booking_sources = None
+    company_source_upload_ready = False
+    if args.enable_company_reports and database_ready and processing_input is not None:
+        try:
+            booking_repository = PostgresBookingReviewRepository(
+                BookingExcelDatabaseConfig("controlled"),
+                connect=connect,
+            ) if connect is not None else PostgresBookingReviewRepository(
+                BookingExcelDatabaseConfig.from_environment()
+            )
+            booking_sources = ProgramBookingSourceCoordinator(
+                processing_input.object_store,
+                booking_repository,
+            )
+            booking_sources.current()
+            booking_sources.draft()
+            company_source_upload_ready = True
+        except Exception:
+            booking_sources = None
+            company_source_upload_ready = False
     application = PortalApplication(
         repository=repository,
         upload=(
             processing_input.coordinator if processing_input is not None else None
         ),
-        agent_results=(
-            agent_writeback.coordinator if agent_writeback is not None else None
-        ),
         monthly=monthly,
         company_reports=company_reports,
+        booking_sources=booking_sources,
         artifact_reader=(
-            ControlledProjectArtifactReader(PROJECT_ROOT) if database_ready else None
+            RoutedArtifactReader(
+                daily_reader=(
+                    ManagedObjectArtifactReader(processing_input.object_store)
+                    if processing_input is not None
+                    else None
+                ),
+                local_reader=ControlledProjectArtifactReader(PROJECT_ROOT),
+            )
+            if database_ready
+            else None
         ),
         health=RuntimeHealth(
             database_ready=database_ready,
@@ -243,8 +227,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
             monthly_ready=monthly_ready,
             download_ready=database_ready,
             company_reports_ready=company_reports_ready,
-            agent_writeback_ready=agent_writeback_ready,
+            company_source_upload_ready=company_source_upload_ready,
         ),
+        credentials=login_credentials,
         secure_cookies=args.secure_cookies,
     )
     try:
@@ -252,8 +237,6 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
     finally:
         if company_reports is not None:
             company_reports.close()
-        if agent_writeback is not None:
-            agent_writeback.close()
         if processing_input is not None:
             processing_input.close()
     return 0
diff --git a/arr_web/server.py b/arr_web/server.py
index 44919d8..be65c35 100644
--- a/arr_web/server.py
+++ b/arr_web/server.py
@@ -31,6 +31,15 @@ def handler_for(application: PortalApplication) -> Type[BaseHTTPRequestHandler]:
             self._dispatch(b"")
 
         def do_POST(self) -> None:  # noqa: N802
+            self._dispatch_with_body()
+
+        def do_PATCH(self) -> None:  # noqa: N802
+            self._dispatch_with_body()
+
+        def do_DELETE(self) -> None:  # noqa: N802
+            self._dispatch_with_body()
+
+        def _dispatch_with_body(self) -> None:
             raw_length = self.headers.get("Content-Length")
             if raw_length is None:
                 self._write(
@@ -60,6 +69,7 @@ def handler_for(application: PortalApplication) -> Type[BaseHTTPRequestHandler]:
                 self.path,
                 {key: value for key, value in self.headers.items()},
                 body,
+                client_id=str(self.client_address[0]),
             )
             self._write(response)
 
diff --git a/arr_web/services.py b/arr_web/services.py
index 4f8dae4..9469d61 100644
--- a/arr_web/services.py
+++ b/arr_web/services.py
@@ -1,29 +1,13 @@
-"""Mutation seams for uploads and deterministic monthly report generation."""
+"""Small active Web mutation ports for ARR2.0."""
 
 from __future__ import annotations
 
-import os
-import tempfile
 import threading
-import uuid
 from dataclasses import dataclass
 from datetime import date
-from pathlib import Path
 from typing import Any, Dict, Protocol
 
-from arr_ingestion.contracts import IngestionError
-from arr_ingestion.direct_contracts import SubmissionGrant
-from arr_ingestion.repository import JobRegistration
-from arr_processing.callbacks import AgentResultWriteback
-from arr_processing.contracts import ProcessingRequest
-from arr_processing.errors import ProcessingError
-from arr_processing.runner import processing_dispatch_idempotency_key
-from arr_storage.store import ManagedObjectStore
-from arr_web.contracts import (
-    PortalError,
-    validate_upload_filename,
-    validate_xml_payload,
-)
+from arr_web.contracts import PortalError
 from monthly_reports.service import MonthlyReportService, RunRequest
 
 
@@ -37,33 +21,7 @@ class MonthlyCoordinator(Protocol):
         ...
 
 
-class AgentResultCoordinator(Protocol):
-    def accept(self, raw_signed_result: bytes) -> Dict[str, Any]:
-        ...
-
-
-class ProcessingStarter(Protocol):
-    def start(self, request: ProcessingRequest) -> Any:
-        ...
-
-
-class DirectJobRepository(Protocol):
-    def register_job(self, registration: JobRegistration) -> None:
-        ...
-
-    def issue_grant(
-        self,
-        job_id: str,
-        attempt_no: int,
-        *,
-        ttl_seconds: int,
-    ) -> SubmissionGrant:
-        ...
-
-
 class UnavailableUploadCoordinator:
-    """Production-safe default until private OSS and the remote file API are wired."""
-
     def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
         raise PortalError(
             "PROCESSING_GATEWAY_UNAVAILABLE",
@@ -81,171 +39,9 @@ class UnavailableMonthlyCoordinator:
         )
 
 
-class UnavailableAgentResultCoordinator:
-    def accept(self, raw_signed_result: bytes) -> Dict[str, Any]:
-        raise PortalError(
-            "AGENT_WRITEBACK_UNAVAILABLE",
-            "Agent 结果回写服务尚未启用",
-            503,
-        )
-
-
-@dataclass
-class ProcessingAgentResultCoordinator:
-    """Web-safe adapter around the authenticated ARR result pipeline."""
-
-    writeback: AgentResultWriteback
-
-    def accept(self, raw_signed_result: bytes) -> Dict[str, Any]:
-        try:
-            return self.writeback.accept(raw_signed_result).to_dict()
-        except (IngestionError, ProcessingError) as error:
-            code = error.code
-            if code in {
-                "PROCESSING_SIGNATURE_INVALID",
-                "PROCESSING_SIGNATURE_EXPIRED",
-            }:
-                status = 401
-                message = "Agent 结果签名无效或已过期"
-            elif code.endswith("_NOT_FOUND") or code in {
-                "JOB_NOT_FOUND",
-                "PROCESSING_JOB_NOT_FOUND",
-            }:
-                status = 404
-                message = "Agent 结果对应的业务任务不存在"
-            elif "CONFLICT" in code or code in {
-                "JOB_TERMINAL",
-                "PROCESSING_RESULT_MISMATCH",
-            }:
-                status = 409
-                message = "Agent 结果与现有业务任务冲突"
-            elif getattr(error, "retryable", False) or code.startswith("DATABASE_"):
-                status = 503
-                message = "Agent 结果暂时无法写入,请使用相同回调重试"
-            else:
-                status = 422
-                message = "Agent 结果未通过业务验收"
-            raise PortalError(code, message, status) from None
-
-
-@dataclass
-class ObjectStoreUploadCoordinator:
-    """ARR upload boundary: private object -> DB registration -> remote dispatch."""
-
-    object_store: ManagedObjectStore
-    ingestion_repository: DirectJobRepository
-    processing_starter: ProcessingStarter
-    processor_version: str
-    rule_set_sha256: str
-    submission_grant_ttl_seconds: int = 900
-
-    def __post_init__(self) -> None:
-        if (
-            not isinstance(self.submission_grant_ttl_seconds, int)
-            or isinstance(self.submission_grant_ttl_seconds, bool)
-            or not 30 <= self.submission_grant_ttl_seconds <= 1800
-        ):
-            raise ValueError("submission grant lifetime is invalid")
-
-    def submit(self, original_filename: str, payload: bytes) -> Dict[str, Any]:
-        validate_upload_filename(original_filename)
-        validate_xml_payload(payload)
-        job_id = "arrjob-" + uuid.uuid4().hex
-        attempt_no = 1
-        try:
-            with tempfile.TemporaryDirectory(prefix="arr-web-upload-") as temporary:
-                source_path = Path(temporary) / "source.xml"
-                descriptor = os.open(
-                    source_path,
-                    os.O_WRONLY | os.O_CREAT | os.O_EXCL,
-                    0o600,
-                )
-                try:
-                    with os.fdopen(descriptor, "wb") as target:
-                        descriptor = -1
-                        target.write(payload)
-                        target.flush()
-                        os.fsync(target.fileno())
-                finally:
-                    if descriptor >= 0:
-                        os.close(descriptor)
-                stored = self.object_store.upload_committed(
-                    job_id=job_id,
-                    attempt_no=attempt_no,
-                    role="source_xml",
-                    source=source_path,
-                    original_filename="source.xml",
-                )
-            source_file_id = "arr_file_" + uuid.uuid4().hex
-            request_without_grant = ProcessingRequest(
-                job_id=job_id,
-                attempt_no=attempt_no,
-                source_file_id=source_file_id,
-                processor_version=self.processor_version,
-                rule_set_sha256=self.rule_set_sha256,
-            )
-            self.ingestion_repository.register_job(
-                JobRegistration(
-                    job_id=job_id,
-                    source=stored.to_artifact_ref(),
-                    processor_version=self.processor_version,
-                    rule_set_sha256=self.rule_set_sha256,
-                    attempt_no=attempt_no,
-                    idempotency_key=processing_dispatch_idempotency_key(
-                        request_without_grant
-                    ),
-                )
-            )
-            grant = self.ingestion_repository.issue_grant(
-                job_id,
-                attempt_no,
-                ttl_seconds=self.submission_grant_ttl_seconds,
-            )
-            if grant.job_id != job_id or grant.attempt_no != attempt_no:
-                raise IngestionError(
-                    "SUBMISSION_GRANT_UNAVAILABLE",
-                    "direct result submission grant identity is invalid",
-                    retryable=True,
-                )
-            request = ProcessingRequest(
-                job_id=job_id,
-                attempt_no=attempt_no,
-                source_file_id=source_file_id,
-                processor_version=self.processor_version,
-                rule_set_sha256=self.rule_set_sha256,
-                submission_grant=grant.submission_grant,
-            )
-            record = self.processing_starter.start(request)
-        except (IngestionError, ProcessingError) as error:
-            status = (
-                503
-                if getattr(error, "retryable", False)
-                or error.code.startswith("DATABASE_")
-                or error.code in {
-                    "OBJECT_STORE_UNAVAILABLE",
-                }
-                else 422
-            )
-            raise PortalError(error.code, error.safe_message, status) from None
-        except Exception:
-            raise PortalError(
-                "PROCESSING_SUBMISSION_FAILED",
-                "文件已安全接收,但处理任务未能启动",
-                503,
-            ) from None
-        remote_status = str(getattr(record, "remote_status", "queued"))
-        return {
-            "job_id": job_id,
-            "status": "queued" if remote_status in {"reserved", "pending"} else remote_status,
-            "attempt_no": attempt_no,
-            "source_sha256": stored.sha256,
-            "source_byte_size": stored.byte_size,
-        }
-
-
 @dataclass
 class ProgramMonthlyCoordinator:
-    """Runs the ordinary database program; it never invokes an Agent."""
+    """Run the deterministic monthly database program."""
 
     service: MonthlyReportService
 
diff --git a/arr_web/static/app.js b/arr_web/static/app.js
index 72f41f3..2fa52b6 100644
--- a/arr_web/static/app.js
+++ b/arr_web/static/app.js
@@ -3,33 +3,78 @@
 
   const state = {
     csrf: "",
+    maxUploadBytes: 25 * 1024 * 1024,
     selectedFile: null,
-    health: { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false },
+    uploadInFlight: false,
+    uploadProgressTimer: null,
+    uploadProgressValue: 0,
+    companySourceFile: null,
+    companySource: null,
+    companySourceDraft: null,
+    companySourceUploading: false,
+    companyReviewLoading: false,
+    companyReviewMutating: false,
+    companyReviewOffset: 0,
+    companyReviewSelectedIds: new Set(),
+    companyReviewConfirmRequest: null,
+    companyReportConfirmRequest: null,
+    health: { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false, company_source_upload_ready: false },
     analytics: null,
     toastTimer: null,
+    jobs: [],
+    selectedJobId: "",
+    jobTrace: null,
+    jobTraceLoading: false,
+    jobTracePollTimer: null,
+    jobsLoading: false,
+    jobsTotal: 0,
+    jobsOffset: 0,
+    monthlyRuns: [],
+    monthlyLoaded: false,
+    monthlyLoading: false,
+    monthlyPollTimer: null,
+    monthlyTotal: 0,
+    monthlyOffset: 0,
     companyReportJobs: [],
     companyReportCurrentJob: null,
     companyReportPollTimer: null,
     companyReportSubmittingPeriod: "",
+    companyHistoryLoading: false,
+    companyReportsTotal: 0,
+    companyReportsOffset: 0,
     companyReportsReady: false,
+    companySourceUploadReady: false,
   };
 
   const BUSINESS_TIME_ZONE = "Asia/Bangkok";
+  const TRACE_POLL_INTERVAL = 4000;
+  const MONTHLY_POLL_INTERVAL = 4000;
+  const HISTORY_PAGE_SIZE = 50;
+  const COMPANY_REVIEW_PAGE_SIZE = 50;
   const COMPANY_REPORT_NAMES = ["LianTai", "QBD", "DY-AI-Easy-KB", "FengRun", "HanaTour"];
   const COMPANY_REPORT_PERIODS = ["01-10", "11-20", "21-month-end"];
+  const DAILY_UPLOAD_PROGRESS_STAGES = [
+    { value: 42, label: "正在运行固定处理器", delay: 600 },
+    { value: 68, label: "正在独立验收结果", delay: 1400 },
+    { value: 88, label: "正在提交数据库", delay: 1800 },
+  ];
 
   const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"];
   const jobStatus = {
+    received: ["已接收", "running"],
     uploaded: ["已上传", "running"],
     queued: ["等待处理", "running"],
     running: ["处理中", "running"],
     delivered: ["等待验收", "running"],
     validating: ["正在验收", "running"],
+    accepted: ["已完成", "success"],
     succeeded: ["已完成", "success"],
+    rejected: ["验收失败", "failed"],
     failed: ["处理失败", "failed"],
     cancelled: ["已取消", "failed"],
   };
   const reportStatus = {
+    reserved: ["生成中", "running"],
     generating: ["生成中", "running"],
     validated: ["已验收", "running"],
     active: ["当前版本", "success current"],
@@ -77,10 +122,24 @@
     state.toastTimer = window.setTimeout(() => toast.classList.remove("is-visible"), 3600);
   }
 
+  function loginLocation() {
+    const next = location.pathname === "/h5" ? "/h5" : "/";
+    return `/login?next=${encodeURIComponent(next)}`;
+  }
+
+  function redirectToLogin() {
+    window.location.replace(loginLocation());
+  }
+
   async function api(path, options = {}) {
-    const headers = new Headers(options.headers || {});
-    if (options.method && options.method !== "GET") headers.set("X-ARR-CSRF", state.csrf);
-    const response = await fetch(path, { ...options, headers, credentials: "same-origin" });
+    const { returnEnvelope = false, ...requestOptions } = options;
+    const headers = new Headers(requestOptions.headers || {});
+    if (requestOptions.method && requestOptions.method !== "GET") headers.set("X-ARR-CSRF", state.csrf);
+    const response = await fetch(path, { ...requestOptions, headers, credentials: "same-origin" });
+    if (response.status === 401) {
+      redirectToLogin();
+      throw new Error("登录状态已失效");
+    }
     let payload;
     try {
       payload = await response.json();
@@ -90,7 +149,32 @@
     if (!response.ok || !payload.ok) {
       throw new Error(payload?.error?.message || "请求未完成");
     }
-    return payload.data;
+    return returnEnvelope ? payload : payload.data;
+  }
+
+  function readPage(envelope) {
+    const items = Array.isArray(envelope?.data) ? envelope.data : [];
+    const rawTotal = Number(envelope?.pagination?.total);
+    const total = Number.isInteger(rawTotal) && rawTotal >= 0 ? rawTotal : items.length;
+    return { items, total };
+  }
+
+  function pageOffset(total, offset, direction) {
+    const lastOffset = total > 0 ? Math.floor((total - 1) / HISTORY_PAGE_SIZE) * HISTORY_PAGE_SIZE : 0;
+    return Math.max(0, Math.min(offset + direction * HISTORY_PAGE_SIZE, lastOffset));
+  }
+
+  function renderPagination(scope, total, offset, loading = false) {
+    const pageCount = Math.max(1, Math.ceil(total / HISTORY_PAGE_SIZE));
+    const page = Math.min(pageCount, Math.floor(offset / HISTORY_PAGE_SIZE) + 1);
+    const start = total > 0 && offset < total ? offset + 1 : 0;
+    const end = start > 0 ? Math.min(offset + HISTORY_PAGE_SIZE, total) : 0;
+    $(`#${scope}-pagination-summary`).textContent = total > 0
+      ? `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}`
+      : "共 0 条";
+    $(`#${scope}-page-label`).textContent = `第 ${formatInteger(page)} / ${formatInteger(pageCount)} 页`;
+    $(`#${scope}-prev`).disabled = loading || offset <= 0;
+    $(`#${scope}-next`).disabled = loading || offset + HISTORY_PAGE_SIZE >= total;
   }
 
   function formatInteger(value) {
@@ -143,19 +227,47 @@
     });
     if (updateHash) history.replaceState(null, "", `#${valid}`);
     if (valid === "bi" && !state.analytics) loadAnalytics();
-    if (valid === "usage" && state.csrf) loadCompanyReportHistory(true);
+    if (valid === "usage" && state.csrf) {
+      loadCompanySource();
+      loadCompanyDraft();
+      loadCompanyReportHistory(true);
+    }
+    if (valid === "monthly" && state.csrf) loadMonthly(false, state.monthlyLoaded);
+    else clearMonthlyPoll();
+    if (taskLogIsOpen()) scheduleTracePoll(250);
+    else clearTracePoll();
   }
 
   async function initSession() {
     const session = await api("/api/session");
     state.csrf = session.csrf_token;
+    state.maxUploadBytes = Number(session.max_upload_bytes) || state.maxUploadBytes;
+    const button = $("#logout-button");
+    if (!session.username) return;
+    button.hidden = false;
+    button.disabled = false;
+    button.title = `${session.username} · 退出登录`;
+  }
+
+  async function logout() {
+    const button = $("#logout-button");
+    button.disabled = true;
+    try {
+      await api("/api/logout", { method: "POST" });
+      window.location.replace("/login");
+    } catch (error) {
+      if (document.visibilityState === "visible") {
+        showToast(error.message || "退出登录失败,请重试", true);
+        button.disabled = false;
+      }
+    }
   }
 
   async function loadHealth() {
     try {
       state.health = await api("/api/health");
     } catch (_) {
-      state.health = { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false };
+      state.health = { database_ready: false, processing_ready: false, monthly_ready: false, download_ready: false, company_reports_ready: false, company_source_upload_ready: false };
     }
     const node = $("#system-status");
     node.classList.toggle("is-ready", state.health.database_ready);
@@ -166,26 +278,26 @@
     fileInput.disabled = !state.health.processing_ready;
     $("#dropzone").classList.toggle("is-disabled", !state.health.processing_ready);
     $("#upload-hint").textContent = state.health.processing_ready ? "" : "文件接收服务尚未完成生产接线。";
-    $("#generate-monthly").disabled = !state.health.monthly_ready;
-    if (!state.health.monthly_ready) $("#generate-monthly").title = "月报生成服务尚未接线";
     state.companyReportsReady = Boolean(state.health.company_reports_ready);
+    state.companySourceUploadReady = Boolean(state.health.company_source_upload_ready);
     $("#company-report-unavailable").hidden = state.companyReportsReady;
+    updateCompanySourceUploadControls();
     updateCompanyReportControls();
   }
 
   function renderJobs(jobs) {
     const body = $("#jobs-body");
     if (!jobs.length) {
-      body.innerHTML = '本月暂无日报处理记录';
+      body.innerHTML = '本月暂无 Daily Report 记录';
       $("#metric-arrival").textContent = "—";
       $("#metric-duration").textContent = "—";
       $("#metric-rooms").textContent = "—";
       return;
     }
     body.innerHTML = jobs.map((job) => `
-      
-        ${escapeHtml(job.filename)}
-        ${chip(job.status, jobStatus)}
+      
+        ${escapeHtml(job.filename || "—")}
+        ${chip(job.status, jobStatus)}${job.failure_code ? `${escapeHtml(job.failure_code)}` : ""}
         ${escapeHtml(job.arrival_date || "—")}
         ${formatInteger(job.no_of_rooms)}
         ${duration(job)}
@@ -198,25 +310,300 @@
     $("#metric-rooms").textContent = latest ? formatInteger(latest.no_of_rooms) : "—";
   }
 
-  async function loadJobs(showErrors = false) {
+  function taskLogIsOpen() {
+    return Boolean($("#task-log-dialog")?.open);
+  }
+
+  function openTaskLog() {
+    const dialog = $("#task-log-dialog");
+    if (!dialog || taskLogIsOpen()) return;
+    if (typeof dialog.showModal === "function") dialog.showModal();
+    else dialog.setAttribute("open", "");
+    dialog.scrollTop = 0;
+    scheduleTracePoll(250);
+  }
+
+  function closeTaskLog() {
+    const dialog = $("#task-log-dialog");
+    if (!dialog || !taskLogIsOpen()) return;
+    if (typeof dialog.close === "function") dialog.close();
+    else {
+      dialog.removeAttribute("open");
+      clearTracePoll();
+    }
+  }
+
+  function clearTracePoll() {
+    window.clearTimeout(state.jobTracePollTimer);
+    state.jobTracePollTimer = null;
+  }
+
+  function setTraceLiveState(copy, style = "") {
+    const node = $("#trace-live-state");
+    node.className = `trace-live-state${style ? ` ${style}` : ""}`;
+    node.textContent = copy;
+  }
+
+  function traceConsoleValue(value) {
+    return JSON.stringify(value ?? null);
+  }
+
+  function copyTextWithSelection(text) {
+    const activeElement = document.activeElement;
+    const textarea = document.createElement("textarea");
+    textarea.value = text;
+    textarea.setAttribute("readonly", "");
+    textarea.style.position = "fixed";
+    textarea.style.inset = "0 auto auto 0";
+    textarea.style.opacity = "0";
+    textarea.style.pointerEvents = "none";
+    document.body.appendChild(textarea);
+    textarea.focus();
+    textarea.select();
+    textarea.setSelectionRange(0, textarea.value.length);
+    let copied = false;
     try {
-      renderJobs(await api(`/api/jobs?month=${encodeURIComponent(localMonth())}`));
+      copied = document.execCommand("copy");
+    } finally {
+      textarea.remove();
+      activeElement?.focus?.();
+    }
+    return copied;
+  }
+
+  async function writeClipboardText(text) {
+    let clipboardError = null;
+    if (navigator.clipboard?.writeText) {
+      try {
+        await navigator.clipboard.writeText(text);
+        return;
+      } catch (error) {
+        clipboardError = error;
+      }
+    }
+    if (copyTextWithSelection(text)) return;
+    throw clipboardError || new Error("浏览器未允许访问剪贴板");
+  }
+
+  async function copyAllTraceLogs() {
+    const button = $("#copy-trace");
+    const text = $("#process-log").textContent || "";
+    if (!state.jobTrace || !text.trim()) {
+      showToast("暂无可复制的任务日志", true);
+      return;
+    }
+    button.disabled = true;
+    button.textContent = "复制中…";
+    try {
+      await writeClipboardText(text);
+      button.textContent = "已复制";
+      showToast(`已复制全部任务日志(${text.split("\n").length} 行)`);
+    } catch (_) {
+      button.textContent = "复制失败";
+      showToast("无法复制日志,请检查浏览器剪贴板权限", true);
+    } finally {
+      window.setTimeout(() => {
+        button.textContent = "复制全部日志";
+        button.disabled = !state.jobTrace;
+      }, 1600);
+    }
+  }
+
+  function renderTracePlaceholder(title, detail, tone = "", logCopy = "") {
+    const liveCopy = tone === "error" ? "ERROR" : tone === "running" ? "LOADING" : "IDLE";
+    setTraceLiveState(liveCopy, tone ? `is-${tone}` : "");
+    $("#refresh-trace").disabled = !state.selectedJobId;
+    $("#copy-trace").disabled = true;
+    $("#trace-log-count").textContent = "0 events";
+    $("#process-log").textContent = [
+      `$ arr trace${state.selectedJobId ? ` --job ${state.selectedJobId}` : ""}`,
+      `# ${title}`,
+      `# ${logCopy || detail || "请上传文件,或从下方选择一个历史任务。"}`,
+    ].join("\n");
+  }
+
+  function renderTraceConsole(trace) {
+    const job = trace.job || {};
+    const logs = trace.logs || [];
+    const lines = [
+      `$ arr trace --job ${job.job_id || state.selectedJobId || "-"}`,
+      `trace_version=${traceConsoleValue(trace.trace_version)} status=${traceConsoleValue(job.status)} active=${Boolean(job.active)} current_stage=${traceConsoleValue(job.current_stage)}`,
+      `attempt_no=${traceConsoleValue(job.attempt_no)} remote_run_id=${traceConsoleValue(job.remote_run_id)} delivery_mode=${traceConsoleValue(job.delivery_mode)}`,
+      `created_at=${traceConsoleValue(job.created_at)} updated_at=${traceConsoleValue(job.updated_at)} finished_at=${traceConsoleValue(job.finished_at)}`,
+      `evidence=${JSON.stringify(trace.evidence || {})}`,
+      "--------------------------------------------------------------------------------",
+      ...logs.map((log) => {
+        const level = String(log.level || "info").toUpperCase().padEnd(7, " ");
+        return `${log.timestamp || "-"} ${level} stage=${log.stage || "-"} code=${log.code || "-"} title=${traceConsoleValue(log.title)} message=${traceConsoleValue(log.message)} details=${JSON.stringify(log.details || {})}`;
+      }),
+      "--------------------------------------------------------------------------------",
+    ];
+    if (job.failure) {
+      lines.push(`FAILURE stage=${job.current_stage || "-"} code=${job.failure.code || "-"} message=${traceConsoleValue(job.failure.message)}`);
+    }
+    lines.push(`END status=${job.status || "unknown"} events=${logs.length} refreshed_at=${traceConsoleValue(trace.refreshed_at)}`);
+
+    const output = $("#process-log");
+    output.textContent = lines.join("\n");
+    output.scrollTop = output.scrollHeight;
+    $("#trace-log-count").textContent = `${logs.length} events`;
+    $("#copy-trace").disabled = false;
+  }
+
+  function syncTraceToJobs(trace) {
+    const summary = trace.job || {};
+    const item = state.jobs.find((job) => job.job_id === summary.job_id);
+    if (!item) return;
+    item.status = summary.status;
+    item.failure_code = summary.failure?.code || null;
+    item.updated_at = summary.updated_at || item.updated_at;
+    item.finished_at = summary.finished_at || item.finished_at;
+    item.arrival_date = summary.business_date || item.arrival_date;
+    item.output_rows = summary.output_rows ?? item.output_rows;
+    item.version_no = summary.version_no ?? item.version_no;
+    renderJobs(state.jobs);
+  }
+
+  function renderTrace(trace) {
+    const job = trace.job || {};
+    syncTraceToJobs(trace);
+    if (job.failure) {
+      setTraceLiveState(`FAILED / ${job.current_stage || "unknown"} / ${job.failure.code || "unknown"}`, "is-error");
+    } else if (job.active) {
+      setTraceLiveState(`RUNNING / auto refresh ${TRACE_POLL_INTERVAL / 1000}s`, "is-live");
+    } else if (job.status === "succeeded") {
+      setTraceLiveState("SUCCEEDED", "is-complete");
+    } else {
+      setTraceLiveState(String(job.status || "UNKNOWN").toUpperCase());
+    }
+    $("#refresh-trace").disabled = !job.job_id;
+    renderTraceConsole(trace);
+  }
+
+  function scheduleTracePoll(delay = TRACE_POLL_INTERVAL) {
+    clearTracePoll();
+    if (
+      !state.selectedJobId
+      || !state.jobTrace?.job?.active
+      || document.hidden
+      || !taskLogIsOpen()
+    ) return;
+    state.jobTracePollTimer = window.setTimeout(() => loadJobTrace(false, true), delay);
+  }
+
+  async function loadJobTrace(showErrors = false, silent = false) {
+    const jobId = state.selectedJobId;
+    if (!jobId || state.jobTraceLoading) return;
+    state.jobTraceLoading = true;
+    if (!silent && state.jobTrace?.job?.job_id !== jobId) {
+      renderTracePlaceholder("正在读取任务日志", `任务 ${jobId}`, "running", "正在聚合数据库中的处理证据…");
+      setTraceLiveState("正在读取", "is-live");
+    }
+    try {
+      const trace = await api(`/api/jobs/${encodeURIComponent(jobId)}/trace`);
+      if (state.selectedJobId !== jobId) return;
+      state.jobTrace = trace;
+      renderTrace(trace);
+    } catch (error) {
+      if (state.selectedJobId !== jobId) return;
+      if (!state.jobTrace) {
+        renderTracePlaceholder("任务日志暂时无法读取", error.message, "error", "服务端没有返回可用日志。");
+      }
+      setTraceLiveState(showErrors ? "日志刷新失败" : "自动刷新失败", "is-error");
+      if (showErrors) showToast(error.message, true);
+    } finally {
+      const selectionChanged = state.selectedJobId !== jobId;
+      state.jobTraceLoading = false;
+      if (selectionChanged) {
+        loadJobTrace(false, false);
+        return;
+      }
+      scheduleTracePoll(state.jobTrace?.job?.active ? TRACE_POLL_INTERVAL : 0);
+    }
+  }
+
+  async function selectJob(jobId, { showLog = false } = {}) {
+    if (!jobId) return;
+    clearTracePoll();
+    const changed = state.selectedJobId !== jobId;
+    state.selectedJobId = jobId;
+    if (changed) state.jobTrace = null;
+    renderJobs(state.jobs);
+    if (showLog) openTaskLog();
+    await loadJobTrace(true, !changed);
+  }
+
+  async function loadJobs(showErrors = false, loadTrace = true) {
+    if (state.jobsLoading) return;
+    state.jobsLoading = true;
+    renderPagination("jobs", state.jobsTotal, state.jobsOffset, true);
+    try {
+      const month = encodeURIComponent(localMonth());
+      let page = readPage(await api(
+        `/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`,
+        { returnEnvelope: true },
+      ));
+      if (!page.items.length && page.total > 0 && state.jobsOffset >= page.total) {
+        state.jobsOffset = pageOffset(page.total, state.jobsOffset, -1);
+        page = readPage(await api(
+          `/api/jobs?month=${month}&limit=${HISTORY_PAGE_SIZE}&offset=${state.jobsOffset}`,
+          { returnEnvelope: true },
+        ));
+      }
+      state.jobs = page.items;
+      state.jobsTotal = page.total;
+      renderJobs(state.jobs);
+      if (!state.selectedJobId && state.jobs.length) {
+        await selectJob(state.jobs[0].job_id);
+      } else if (loadTrace && state.selectedJobId) {
+        await loadJobTrace(showErrors, Boolean(state.jobTrace));
+      } else if (!state.jobs.length && !state.selectedJobId) {
+        renderTracePlaceholder(
+          "等待上传 ARR.XML",
+          "新任务创建后,这里会显示服务端保存的全流程日志。",
+        );
+        setTraceLiveState("未选择任务");
+      }
     } catch (error) {
       $("#jobs-body").innerHTML = '日报记录暂时无法读取';
       if (showErrors) showToast(error.message, true);
+    } finally {
+      state.jobsLoading = false;
+      renderPagination("jobs", state.jobsTotal, state.jobsOffset);
     }
   }
 
+  function clearMonthlyPoll() {
+    window.clearTimeout(state.monthlyPollTimer);
+    state.monthlyPollTimer = null;
+  }
+
+  function monthlyViewActive() {
+    return !document.hidden && $("#panel-monthly")?.classList.contains("is-active");
+  }
+
+  function setMonthlyLiveState(copy, style = "") {
+    const node = $("#monthly-live-state");
+    if (!node) return;
+    node.className = `trace-live-state${style ? ` ${style}` : ""}`;
+    node.textContent = copy;
+  }
+
+  function scheduleMonthlyPoll(delay = MONTHLY_POLL_INTERVAL) {
+    clearMonthlyPoll();
+    if (!monthlyViewActive()) return;
+    state.monthlyPollTimer = window.setTimeout(() => loadMonthly(false, true), delay);
+  }
+
   function renderMonthly(runs) {
     const body = $("#monthly-body");
     if (!runs.length) {
-      body.innerHTML = '该月份暂无月报处理记录';
+      body.innerHTML = '该月份暂无月报处理记录';
       return;
     }
     body.innerHTML = runs.map((run) => `
       
-        V${String(run.version_no).padStart(2, "0")}
-        ${escapeHtml(run.as_of_date || "—")}
+        ${escapeHtml(run.max_arrival_date || run.as_of_date || "—")}
         ${chip(run.status, reportStatus)}
         ${formatInteger(run.row_count)}
         ${formatInteger(run.channel_count)}
@@ -225,13 +612,49 @@
       `).join("");
   }
 
-  async function loadMonthly(showErrors = false) {
-    const month = $("#monthly-month").value || localMonth();
+  async function loadMonthly(showErrors = false, silent = false) {
+    if (state.monthlyLoading) return;
+    state.monthlyLoading = true;
+    const month = localMonth();
+    const previousTotal = state.monthlyTotal;
+    renderPagination("monthly", state.monthlyTotal, state.monthlyOffset, true);
+    if (!silent) setMonthlyLiveState("正在读取…", "is-live");
     try {
-      renderMonthly(await api(`/api/monthly-runs?month=${encodeURIComponent(month)}`));
+      const encodedMonth = encodeURIComponent(month);
+      let page = readPage(await api(
+        `/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.monthlyOffset}`,
+        { returnEnvelope: true },
+      ));
+      const hasNewPublication = state.monthlyLoaded && page.total > previousTotal;
+      if (hasNewPublication && state.monthlyOffset > 0) {
+        state.monthlyOffset = 0;
+        page = readPage(await api(
+          `/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=0`,
+          { returnEnvelope: true },
+        ));
+      } else if (!page.items.length && page.total > 0 && state.monthlyOffset >= page.total) {
+        state.monthlyOffset = pageOffset(page.total, state.monthlyOffset, -1);
+        page = readPage(await api(
+          `/api/monthly-runs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.monthlyOffset}`,
+          { returnEnvelope: true },
+        ));
+      }
+      state.monthlyRuns = page.items;
+      state.monthlyTotal = page.total;
+      state.monthlyLoaded = true;
+      renderMonthly(state.monthlyRuns);
+      setMonthlyLiveState(`自动更新 · ${MONTHLY_POLL_INTERVAL / 1000} 秒`, "is-live");
+      if (hasNewPublication) showToast("新月报已自动加入列表");
     } catch (error) {
-      $("#monthly-body").innerHTML = '月报记录暂时无法读取';
+      if (!silent || !state.monthlyLoaded) {
+        $("#monthly-body").innerHTML = '月报记录暂时无法读取';
+      }
+      setMonthlyLiveState("自动更新重试中", "is-error");
       if (showErrors) showToast(error.message, true);
+    } finally {
+      state.monthlyLoading = false;
+      renderPagination("monthly", state.monthlyTotal, state.monthlyOffset);
+      scheduleMonthlyPoll();
     }
   }
 
@@ -316,62 +739,133 @@
     }
   }
 
+  function encodedFilename(value) {
+    const filenameBytes = new TextEncoder().encode(value);
+    let binary = "";
+    filenameBytes.forEach((byte) => { binary += String.fromCharCode(byte); });
+    return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
+  }
+
+  function clearUploadProgressTimer() {
+    window.clearTimeout(state.uploadProgressTimer);
+    state.uploadProgressTimer = null;
+  }
+
+  function setUploadProgress(value, label, tone = "running") {
+    const progress = $("#upload-progress");
+    const labelNode = $("#upload-progress-label");
+    const percentNode = $("#upload-progress-percent");
+    const track = $("#upload-progress-track");
+    const bar = $("#upload-progress-bar");
+    if (!progress || !labelNode || !percentNode || !track || !bar) return;
+    const bounded = Math.max(0, Math.min(100, Math.round(Number(value) || 0)));
+    state.uploadProgressValue = bounded;
+    progress.hidden = false;
+    progress.className = `upload-progress${tone ? ` is-${tone}` : ""}`;
+    labelNode.textContent = label;
+    percentNode.textContent = `${bounded}%`;
+    track.setAttribute("aria-valuenow", String(bounded));
+    track.setAttribute("aria-valuetext", `${label} ${bounded}%`);
+    bar.style.transform = `scaleX(${bounded / 100})`;
+  }
+
+  function resetUploadProgress() {
+    clearUploadProgressTimer();
+    const progress = $("#upload-progress");
+    if (!progress) return;
+    progress.hidden = true;
+    progress.className = "upload-progress";
+    state.uploadProgressValue = 0;
+    $("#upload-progress-label").textContent = "等待开始";
+    $("#upload-progress-percent").textContent = "0%";
+    $("#upload-progress-track").setAttribute("aria-valuenow", "0");
+    $("#upload-progress-track").setAttribute("aria-valuetext", "等待开始 0%");
+    $("#upload-progress-bar").style.transform = "scaleX(0)";
+  }
+
+  function startUploadProgress() {
+    clearUploadProgressTimer();
+    setUploadProgress(12, "正在上传 ARR.XML");
+    let stageIndex = 0;
+    const advance = () => {
+      const stage = DAILY_UPLOAD_PROGRESS_STAGES[stageIndex];
+      if (!stage) return;
+      setUploadProgress(stage.value, stage.label);
+      stageIndex += 1;
+      if (DAILY_UPLOAD_PROGRESS_STAGES[stageIndex]) {
+        state.uploadProgressTimer = window.setTimeout(advance, stage.delay);
+      }
+    };
+    state.uploadProgressTimer = window.setTimeout(advance, 500);
+  }
+
+  function finishUploadProgress(success, label) {
+    clearUploadProgressTimer();
+    if (success) {
+      setUploadProgress(100, label, "success");
+      return;
+    }
+    setUploadProgress(Math.max(12, state.uploadProgressValue), label, "error");
+  }
+
   async function handleUpload() {
     if (!state.selectedFile || !state.health.processing_ready) return;
+    const file = state.selectedFile;
     const button = $("#upload-button");
+    const fileInput = $("#xml-file");
+    const dropzone = $("#dropzone");
+    state.uploadInFlight = true;
     button.disabled = true;
-    $("#process-state").className = "process-state is-running";
-    $("#process-copy").textContent = "正在处理ARR.XML文件";
-    $("#process-detail").textContent = "正在安全上传并创建处理任务。";
+    fileInput.disabled = true;
+    dropzone.classList.add("is-disabled");
+    button.setAttribute("aria-busy", "true");
+    button.textContent = "处理中…";
+    clearTracePoll();
+    state.selectedJobId = "";
+    state.jobTrace = null;
+    renderJobs(state.jobs);
+    renderTracePlaceholder(
+      "正在处理ARR.XML文件",
+      "正在安全上传、运行固定处理器、独立验收并提交数据库。",
+      "running",
+      "处理完成后将立即读取服务端全流程日志…",
+    );
+    setTraceLiveState("正在创建任务", "is-live");
+    startUploadProgress();
     try {
-      const filenameBytes = new TextEncoder().encode(state.selectedFile.name);
-      let binary = "";
-      filenameBytes.forEach((byte) => { binary += String.fromCharCode(byte); });
-      const encoded = btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
       const receipt = await api("/api/jobs", {
         method: "POST",
-        headers: { "Content-Type": "application/xml", "X-ARR-Filename-B64": encoded },
-        body: await state.selectedFile.arrayBuffer(),
+        headers: { "Content-Type": "application/xml", "X-ARR-Filename-B64": encodedFilename(file.name) },
+        body: await file.arrayBuffer(),
       });
-      $("#process-state").className = "process-state is-success";
-      $("#process-copy").textContent = "文件已进入处理队列";
-      $("#process-detail").textContent = receipt.job_id ? `任务 ${receipt.job_id}` : "处理状态将自动更新。";
       state.selectedFile = null;
       $("#xml-file").value = "";
       $("#selected-file").textContent = "尚未选择文件";
-      showToast("ARR.XML 已提交");
-      await loadJobs();
+      const failed = receipt.status === "failed";
+      finishUploadProgress(!failed, failed ? "处理失败" : "处理完成");
+      showToast(failed ? "ARR.XML 处理失败,请查看任务日志" : "ARR.XML 已处理并完成入库", failed);
+      state.jobsOffset = 0;
+      await loadJobs(false, false);
+      if (receipt.job_id) {
+        await selectJob(receipt.job_id);
+      } else {
+        renderTracePlaceholder("处理已结束", "任务编号暂不可用,请刷新 Daily Report。", "success");
+      }
     } catch (error) {
-      $("#process-state").className = "process-state is-error";
-      $("#process-copy").textContent = "文件未能提交";
-      $("#process-detail").textContent = error.message;
+      finishUploadProgress(false, "处理失败");
+      renderTracePlaceholder("处理未能完成", error.message, "error", "请刷新 Daily Report,确认是否已登记失败任务。");
+      setTraceLiveState("处理失败", "is-error");
       showToast(error.message, true);
     } finally {
+      state.uploadInFlight = false;
+      fileInput.disabled = !state.health.processing_ready;
+      dropzone.classList.toggle("is-disabled", !state.health.processing_ready);
+      button.removeAttribute("aria-busy");
+      button.textContent = "开始处理";
       button.disabled = !state.selectedFile || !state.health.processing_ready;
     }
   }
 
-  async function generateMonthly() {
-    const button = $("#generate-monthly");
-    if (!state.health.monthly_ready) return;
-    button.disabled = true;
-    button.textContent = "正在生成…";
-    try {
-      await api("/api/monthly-runs", {
-        method: "POST",
-        headers: { "Content-Type": "application/json" },
-        body: JSON.stringify({ month: $("#monthly-month").value, as_of_date: $("#monthly-as-of").value }),
-      });
-      showToast("标准月报已生成并留存");
-      await Promise.all([loadMonthly(), loadMonthsAndAnalytics()]);
-    } catch (error) {
-      showToast(error.message, true);
-    } finally {
-      button.disabled = !state.health.monthly_ready;
-      button.textContent = "生成标准月报";
-    }
-  }
-
   async function loadMonthsAndAnalytics() {
     try {
       const months = await api("/api/months");
@@ -383,6 +877,436 @@
     await loadAnalytics();
   }
 
+  function setCompanySourceError(message = "") {
+    const node = $("#company-source-error");
+    node.textContent = message;
+    node.hidden = !message;
+  }
+
+  function updateCompanySourceUploadControls() {
+    const busy = state.companySourceUploading || state.companyReviewMutating || isCompanyReportActive();
+    const enabled = state.companySourceUploadReady && !busy;
+    const input = $("#company-excel-file");
+    input.disabled = !enabled;
+    $("#company-excel-dropzone").classList.toggle("is-disabled", !enabled);
+    const button = $("#company-upload-button");
+    button.disabled = !enabled || !state.companySourceFile;
+    button.setAttribute("aria-busy", String(state.companySourceUploading));
+    $("span", button).textContent = state.companySourceUploading ? "正在提取" : "提取并核对";
+  }
+
+  function clearCompanyReviewSelection() {
+    state.companyReviewSelectedIds.clear();
+  }
+
+  function syncCompanyReviewSelection(items = [], busy = false) {
+    const visibleIds = items
+      .map((item) => Number(item.item_id))
+      .filter((itemId) => Number.isInteger(itemId) && itemId > 0);
+    const visibleIdSet = new Set(visibleIds);
+    [...state.companyReviewSelectedIds].forEach((itemId) => {
+      if (!visibleIdSet.has(itemId)) state.companyReviewSelectedIds.delete(itemId);
+    });
+    const selectedCount = state.companyReviewSelectedIds.size;
+    const pageCheckbox = $("#company-review-select-page");
+    pageCheckbox.checked = visibleIds.length > 0 && selectedCount === visibleIds.length;
+    pageCheckbox.indeterminate = selectedCount > 0 && selectedCount < visibleIds.length;
+    pageCheckbox.disabled = busy || visibleIds.length === 0;
+    $("#company-review-selection-count").textContent = `已选 ${formatInteger(selectedCount)} 条`;
+    $("#company-review-delete-selected").disabled = busy || selectedCount === 0;
+    $$("[data-review-select]", $("#company-review-body")).forEach((checkbox) => {
+      const itemId = Number(checkbox.value);
+      const selected = state.companyReviewSelectedIds.has(itemId);
+      checkbox.checked = selected;
+      checkbox.disabled = busy;
+      const row = checkbox.closest("[data-review-item-id]");
+      row?.classList.toggle("is-selected", selected);
+      row?.setAttribute("aria-selected", String(selected));
+    });
+  }
+
+  function renderCompanyDraft(page) {
+    const panel = $("#company-review-panel");
+    const body = $("#company-review-body");
+    const empty = $("#company-review-empty");
+    if (!page?.summary) {
+      panel.hidden = true;
+      panel.classList.remove("is-busy");
+      $("#company-review-filename").textContent = "";
+      $("#company-review-filename").removeAttribute("title");
+      body.innerHTML = "";
+      empty.hidden = true;
+      clearCompanyReviewSelection();
+      syncCompanyReviewSelection();
+      return;
+    }
+
+    const summary = page.summary;
+    const items = Array.isArray(page.items) ? page.items : [];
+    const pagination = page.pagination || {};
+    const pending = Number(summary.pending_items) || 0;
+    const confirmed = Number(summary.confirmed_items) || 0;
+    const deleted = Number(summary.deleted_items) || 0;
+    const busy = state.companyReviewLoading || state.companyReviewMutating;
+    const filename = String(summary.filename || "").trim();
+    panel.hidden = false;
+    panel.classList.toggle("is-busy", busy);
+    $("#company-review-filename").textContent = filename || "未记录文件名";
+    if (filename) $("#company-review-filename").title = filename;
+    else $("#company-review-filename").removeAttribute("title");
+    $("#company-review-confirmed").textContent = formatInteger(confirmed);
+    $("#company-review-pending").textContent = formatInteger(pending);
+    $("#company-review-deleted").textContent = formatInteger(deleted);
+
+    body.innerHTML = items.map((item) => {
+      const isPending = item.review_status === "pending";
+      const itemId = Number(item.item_id);
+      const selected = state.companyReviewSelectedIds.has(itemId);
+      const tourCode = escapeHtml(item.tour_code || "暂无");
+      const raw = escapeHtml(item.room_type_raw || "暂无");
+      const roomType = escapeHtml(item.room_type || "");
+      const quantity = Number(item.quantity) || 1;
+      return `
+        
+        ${tourCode}
+        ${raw}
+        
+        
+        ${isPending ? "待人工确认" : "已确认"}
+        
+ `; + }).join(""); + empty.hidden = items.length > 0; + syncCompanyReviewSelection(items, busy); + + const total = Number(pagination.total) || 0; + const limit = Number(pagination.limit) || COMPANY_REVIEW_PAGE_SIZE; + const offset = Number(pagination.offset) || 0; + const pageCount = Math.max(1, Math.ceil(total / limit)); + const currentPage = Math.min(pageCount, Math.floor(offset / limit) + 1); + const start = total > 0 ? offset + 1 : 0; + const end = total > 0 ? Math.min(offset + items.length, total) : 0; + $("#company-review-page-summary").textContent = total > 0 + ? `共 ${formatInteger(total)} 条 · 本页 ${formatInteger(start)} 至 ${formatInteger(end)}` + : "共 0 条"; + $("#company-review-page-label").textContent = `第 ${formatInteger(currentPage)} / ${formatInteger(pageCount)} 页`; + $("#company-review-prev").disabled = busy || offset <= 0; + $("#company-review-next").disabled = busy || offset + items.length >= total; + $("#company-review-discard").disabled = busy; + $("#company-review-activate").disabled = busy || pending > 0 || confirmed < 1; + } + + function selectCompanySourceFile(file) { + setCompanySourceError(); + state.companySourceFile = null; + if (file) { + if (!file.name.toLowerCase().endsWith(".xlsx")) { + setCompanySourceError("请选择 .xlsx 格式的 Excel 文件"); + } else if (!file.size) { + setCompanySourceError("Excel 文件为空"); + } else if (file.size > state.maxUploadBytes) { + setCompanySourceError("Excel 文件超过 25 MB"); + } else { + state.companySourceFile = file; + } + } + $("#company-selected-file").textContent = state.companySourceFile + ? `${state.companySourceFile.name} · ${formatInteger(state.companySourceFile.size / 1024)} KB` + : "尚未选择文件"; + updateCompanySourceUploadControls(); + } + + async function loadCompanySource(showErrors = false) { + if (!state.companySourceUploadReady) { + state.companySource = null; + updateCompanyReportControls(); + return; + } + try { + state.companySource = await api("/api/company-reports/source"); + setCompanySourceError(); + } catch (error) { + state.companySource = null; + setCompanySourceError("当前 Excel 数据源暂时无法读取"); + if (showErrors) showToast(error.message, true); + } + updateCompanyReportControls(); + } + + async function loadCompanyDraft(showErrors = false) { + if (!state.companySourceUploadReady) { + state.companySourceDraft = null; + clearCompanyReviewSelection(); + renderCompanyDraft(null); + updateCompanyReportControls(); + return; + } + state.companyReviewLoading = true; + renderCompanyDraft(state.companySourceDraft); + try { + let page = await api(`/api/company-reports/source/draft?limit=${COMPANY_REVIEW_PAGE_SIZE}&offset=${state.companyReviewOffset}`); + if (page?.pagination?.total > 0 && !page.items?.length && state.companyReviewOffset > 0) { + state.companyReviewOffset = Math.max(0, Math.floor((page.pagination.total - 1) / COMPANY_REVIEW_PAGE_SIZE) * COMPANY_REVIEW_PAGE_SIZE); + page = await api(`/api/company-reports/source/draft?limit=${COMPANY_REVIEW_PAGE_SIZE}&offset=${state.companyReviewOffset}`); + } + state.companySourceDraft = page; + } catch (error) { + state.companySourceDraft = null; + setCompanySourceError("当前提取结果暂时无法读取"); + if (showErrors) showToast(error.message, true); + } finally { + state.companyReviewLoading = false; + } + renderCompanyDraft(state.companySourceDraft); + updateCompanySourceUploadControls(); + updateCompanyReportControls(); + } + + async function handleCompanySourceUpload() { + if (!state.companySourceFile || !state.companySourceUploadReady || state.companySourceUploading) return; + state.companySourceUploading = true; + setCompanySourceError(); + updateCompanySourceUploadControls(); + updateCompanyReportControls(); + try { + const receipt = await api("/api/company-reports/source", { + method: "POST", + headers: { + "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "X-ARR-Filename-B64": encodedFilename(state.companySourceFile.name), + }, + body: await state.companySourceFile.arrayBuffer(), + }); + state.companySourceDraft = receipt; + state.companyReviewOffset = 0; + clearCompanyReviewSelection(); + state.companySourceFile = null; + $("#company-excel-file").value = ""; + $("#company-selected-file").textContent = "尚未选择文件"; + renderCompanyDraft(state.companySourceDraft); + const pending = Number(receipt?.summary?.pending_items) || 0; + showToast(pending > 0 ? `提取完成,${formatInteger(pending)} 条需要人工确认` : "提取完成,可以确认并启用"); + } catch (error) { + setCompanySourceError(error.message || "Excel 文件未能完成提取"); + showToast(error.message || "Excel 文件未能完成提取", true); + } finally { + state.companySourceUploading = false; + updateCompanySourceUploadControls(); + updateCompanyReportControls(); + } + } + + async function saveCompanyReviewItem(button) { + if (!state.companySourceDraft?.summary || state.companyReviewMutating) return; + const row = button.closest("[data-review-item-id]"); + const itemId = Number(row?.dataset.reviewItemId); + const roomType = $("[data-review-room]", row)?.value.trim() || ""; + const quantity = Number($("[data-review-quantity]", row)?.value); + if (!roomType) { + setCompanySourceError("请填写房型后再保存"); + return; + } + if (!Number.isInteger(quantity) || quantity < 1 || quantity > 9999) { + setCompanySourceError("房间数量必须是 1 至 9999 的整数"); + return; + } + state.companyReviewMutating = true; + setCompanySourceError(); + renderCompanyDraft(state.companySourceDraft); + try { + await api(`/api/company-reports/source/draft/items/${itemId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + draft_id: state.companySourceDraft.summary.draft_id, + room_type: roomType, + quantity, + }), + }); + state.companyReviewMutating = false; + await loadCompanyDraft(); + showToast("房型记录已保存"); + } catch (error) { + state.companyReviewMutating = false; + renderCompanyDraft(state.companySourceDraft); + setCompanySourceError(error.message || "房型记录未能保存"); + showToast(error.message || "房型记录未能保存", true); + } + } + + function companyReviewConfirmIsOpen() { + return Boolean($("#company-review-confirm-dialog")?.open); + } + + function setCompanyReviewConfirmBusy(busy) { + const request = state.companyReviewConfirmRequest; + const dialog = $("#company-review-confirm-dialog"); + const cancel = $("#company-review-confirm-cancel"); + const submit = $("#company-review-confirm-submit"); + dialog.setAttribute("aria-busy", String(busy)); + cancel.disabled = busy; + submit.disabled = busy; + submit.textContent = busy + ? (request?.kind === "discard" ? "正在放弃" : "正在删除") + : (request?.confirmLabel || "确认删除"); + } + + function resetCompanyReviewConfirm(restoreFocus = true) { + const request = state.companyReviewConfirmRequest; + state.companyReviewConfirmRequest = null; + $("#company-review-confirm-dialog").setAttribute("aria-busy", "false"); + $("#company-review-confirm-error").hidden = true; + if (restoreFocus && request?.trigger?.isConnected && !request.trigger.disabled) { + window.requestAnimationFrame(() => request.trigger.focus()); + } + } + + function closeCompanyReviewConfirm({ force = false, restoreFocus = true } = {}) { + if (state.companyReviewMutating && !force) return; + const dialog = $("#company-review-confirm-dialog"); + const request = state.companyReviewConfirmRequest; + if (state.companyReviewConfirmRequest === request) resetCompanyReviewConfirm(restoreFocus); + if (dialog.open && typeof dialog.close === "function") dialog.close(); + else dialog.removeAttribute("open"); + } + + function openCompanyReviewConfirm(request) { + if (!state.companySourceDraft?.summary || state.companyReviewMutating || companyReviewConfirmIsOpen()) return; + const dialog = $("#company-review-confirm-dialog"); + const error = $("#company-review-confirm-error"); + error.textContent = ""; + error.hidden = true; + if (request.kind === "discard") { + state.companyReviewConfirmRequest = { + ...request, + confirmLabel: "确认放弃", + }; + $("#company-review-confirm-title").textContent = "放弃本次提取"; + $("#company-review-confirm-description").textContent = "确认放弃本次提取?"; + $("#company-review-confirm-note").textContent = "当前已做的人工修改和删除将被丢弃,原 Booking 数据源不会改变。"; + } else { + const itemIds = [...new Set(request.itemIds || [])]; + if (!itemIds.length) return; + const single = itemIds.length === 1; + state.companyReviewConfirmRequest = { + ...request, + itemIds, + confirmLabel: single ? "确认删除" : `删除 ${formatInteger(itemIds.length)} 条`, + }; + $("#company-review-confirm-title").textContent = "删除房型记录"; + $("#company-review-confirm-description").textContent = single + ? `确认删除 ${request.tourCode || "这条记录"} 的这条房型记录?` + : `确认删除已选择的 ${formatInteger(itemIds.length)} 条房型记录?`; + $("#company-review-confirm-note").textContent = "此操作只影响本次提取结果,确认启用前原数据源不会改变。"; + } + setCompanyReviewConfirmBusy(false); + if (typeof dialog.showModal === "function") dialog.showModal(); + else dialog.setAttribute("open", ""); + window.requestAnimationFrame(() => $("#company-review-confirm-cancel").focus()); + } + + function deleteCompanyReviewItem(button) { + if (!state.companySourceDraft?.summary || state.companyReviewMutating) return; + const row = button.closest("[data-review-item-id]"); + const itemId = Number(row?.dataset.reviewItemId); + if (!Number.isInteger(itemId) || itemId <= 0) return; + const tourCode = $(".company-review-tour", row)?.textContent?.trim() || "这条记录"; + openCompanyReviewConfirm({ kind: "items", itemIds: [itemId], tourCode, trigger: button }); + } + + function deleteSelectedCompanyReviewItems(event) { + if (!state.companySourceDraft?.summary || state.companyReviewMutating) return; + const itemIds = (state.companySourceDraft.items || []) + .map((item) => Number(item.item_id)) + .filter((itemId) => state.companyReviewSelectedIds.has(itemId)); + if (!itemIds.length) return; + openCompanyReviewConfirm({ kind: "items", itemIds, trigger: event.currentTarget }); + } + + function discardCompanyReviewDraft(event) { + if (!state.companySourceDraft?.summary || state.companyReviewMutating) return; + openCompanyReviewConfirm({ kind: "discard", trigger: event.currentTarget }); + } + + async function confirmCompanyReviewAction() { + const request = state.companyReviewConfirmRequest; + const summary = state.companySourceDraft?.summary; + if (!request || !summary || state.companyReviewMutating) return; + state.companyReviewMutating = true; + setCompanySourceError(); + setCompanyReviewConfirmBusy(true); + renderCompanyDraft(state.companySourceDraft); + try { + if (request.kind === "discard") { + await api("/api/company-reports/source/draft", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft_id: summary.draft_id }), + }); + state.companySourceDraft = null; + state.companyReviewOffset = 0; + state.companyReviewMutating = false; + clearCompanyReviewSelection(); + renderCompanyDraft(null); + updateCompanySourceUploadControls(); + updateCompanyReportControls(); + closeCompanyReviewConfirm({ force: true, restoreFocus: false }); + showToast("本次提取已放弃,原数据源未改变"); + return; + } + + await api("/api/company-reports/source/draft/items", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft_id: summary.draft_id, item_ids: request.itemIds }), + }); + const deletedCount = request.itemIds.length; + clearCompanyReviewSelection(); + state.companyReviewMutating = false; + await loadCompanyDraft(); + closeCompanyReviewConfirm({ force: true, restoreFocus: false }); + showToast(deletedCount === 1 ? "房型记录已删除" : `已删除 ${formatInteger(deletedCount)} 条房型记录`); + } catch (error) { + state.companyReviewMutating = false; + renderCompanyDraft(state.companySourceDraft); + setCompanyReviewConfirmBusy(false); + const message = error.message || (request.kind === "discard" ? "本次提取未能放弃" : "房型记录未能删除"); + const errorNode = $("#company-review-confirm-error"); + errorNode.textContent = message; + errorNode.hidden = false; + setCompanySourceError(message); + showToast(message, true); + } + } + + async function activateCompanyReviewDraft() { + const summary = state.companySourceDraft?.summary; + if (!summary || state.companyReviewMutating || Number(summary.pending_items) > 0) return; + state.companyReviewMutating = true; + setCompanySourceError(); + renderCompanyDraft(state.companySourceDraft); + try { + const source = await api("/api/company-reports/source/draft/activate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ draft_id: summary.draft_id }), + }); + state.companySource = source; + state.companySourceDraft = null; + state.companyReviewOffset = 0; + state.companyReviewMutating = false; + clearCompanyReviewSelection(); + renderCompanyDraft(null); + updateCompanyReportControls(); + showToast("人工核对完成,Booking 数据源已启用"); + } catch (error) { + state.companyReviewMutating = false; + renderCompanyDraft(state.companySourceDraft); + setCompanySourceError(error.message || "Booking 数据源未能启用"); + showToast(error.message || "Booking 数据源未能启用", true); + } + } + function companyMonthEndDay(reportMonth) { const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(reportMonth || ""); if (!match) return null; @@ -390,10 +1314,17 @@ } function companyPeriodRange(reportMonth, period) { - if (period === "01-10") return "01–10"; - if (period === "11-20") return "11–20"; + if (period === "01-10") return "01-10"; + if (period === "11-20") return "11-20"; const monthEnd = companyMonthEndDay(reportMonth); - return monthEnd ? `21–${monthEnd}` : "21–月末"; + return monthEnd ? `21-${monthEnd}` : "21-月末"; + } + + function companyPeriodDisplayLabel(reportMonth, period) { + if (period === "01-10") return "C/O:01-10"; + if (period === "11-20") return "C/O:11-20"; + const monthEnd = companyMonthEndDay(reportMonth); + return monthEnd ? `C/O:21-${monthEnd}` : "C/O:21-30"; } function companyPeriodCountKey(reportMonth, period) { @@ -402,7 +1333,7 @@ return monthEnd ? `21-${monthEnd}` : ""; } - function companyPeriodReleaseDate(reportMonth, period) { + function companyPeriodCompleteDate(reportMonth, period) { const match = /^(\d{4})-(0[1-9]|1[0-2])$/.exec(reportMonth || ""); if (!match) return ""; if (period === "01-10") return `${reportMonth}-11`; @@ -423,12 +1354,12 @@ function monthLabel(value) { const match = /^(\d{4})-(\d{2})$/.exec(value || ""); - return match ? `${match[1]}年${match[2]}月` : "—"; + return match ? `${match[1]}年${match[2]}月` : "未选择"; } function formatDurationSeconds(value) { const seconds = Number(value); - if (!Number.isFinite(seconds)) return "—"; + if (!Number.isFinite(seconds)) return "暂无"; if (seconds < 60) return `${Math.max(1, Math.round(seconds))} 秒`; return `${Math.floor(seconds / 60)} 分 ${Math.round(seconds % 60)} 秒`; } @@ -446,52 +1377,58 @@ function updateCompanyReportControls() { const reportMonth = $("#company-report-month").value || ""; const monthValid = /^\d{4}-(0[1-9]|1[0-2])$/.test(reportMonth); - const active = isCompanyReportActive() || Boolean(state.companyReportSubmittingPeriod); + const currentMonth = localMonth(); + const monthFuture = monthValid && reportMonth > currentMonth; + const reviewOpen = Boolean(state.companySourceDraft?.summary); + const sourceReady = Boolean(state.companySource) && !reviewOpen; + const active = isCompanyReportActive() || Boolean(state.companyReportSubmittingPeriod) || Boolean(state.companyReportConfirmRequest) || state.companySourceUploading || state.companyReviewMutating; const today = localDate(); - const locked = []; - $("#company-report-month").disabled = active; + const monthInput = $("#company-report-month"); + monthInput.max = currentMonth; + monthInput.disabled = active; $$('[data-company-period]').forEach((button) => { const period = button.dataset.companyPeriod; - const releaseDate = companyPeriodReleaseDate(reportMonth, period); - const periodReady = Boolean(releaseDate) && today >= releaseDate; + const completeDate = companyPeriodCompleteDate(reportMonth, period); + const periodComplete = Boolean(completeDate) && today >= completeDate; const range = companyPeriodRange(reportMonth, period); + const displayRange = companyPeriodDisplayLabel(reportMonth, period); const rangeNode = $('[data-company-period-range]', button); const releaseNode = $('[data-company-period-release]', button); - const coNode = $('[data-company-period-co]', button); const statusNode = $('[data-company-period-state]', button); - if (rangeNode) rangeNode.textContent = range; - if (releaseNode) releaseNode.textContent = releaseDate ? `${releaseDate} 00:00 开放` : "选择月份后显示开放时间"; - if (coNode) coNode.textContent = `C/O:${range}`; + if (rangeNode) rangeNode.textContent = displayRange; + if (releaseNode) releaseNode.textContent = completeDate ? `周期结束:${completeDate} 00:00(曼谷)` : "选择月份后显示周期结束时间"; statusNode.className = "company-period-state"; if (!monthValid) { statusNode.textContent = "选择月份"; - } else if (!periodReady) { - statusNode.classList.add("is-locked"); - statusNode.textContent = "待开放"; - locked.push({ period, releaseDate }); + } else if (reviewOpen) { + statusNode.classList.add("is-unavailable"); + statusNode.textContent = "先完成核对"; + } else if (!sourceReady) { + statusNode.classList.add("is-unavailable"); + statusNode.textContent = "先上传 Excel"; } else if (!state.companyReportsReady) { statusNode.classList.add("is-unavailable"); statusNode.textContent = "服务未就绪"; + } else if (monthFuture) { + statusNode.classList.add("is-unavailable"); + statusNode.textContent = "未来月份"; + } else if (!periodComplete) { + statusNode.classList.add("is-in-progress"); + statusNode.textContent = "周期未结束"; } else { - statusNode.classList.add("is-open"); - statusNode.textContent = "已开放"; + statusNode.classList.add("is-complete"); + statusNode.textContent = "周期已结束"; } - button.disabled = active || !state.companyReportsReady || !monthValid || !periodReady; + button.disabled = active || !state.companyReportsReady || !sourceReady || !monthValid || monthFuture; button.setAttribute("aria-busy", String(state.companyReportSubmittingPeriod === period)); if (!monthValid) button.title = "请先选择有效的报表月份"; - else if (!periodReady) button.title = `按规则从曼谷时间 ${releaseDate} 00:00 起可生成`; + else if (reviewOpen) button.title = "请先确认或放弃当前 Excel 提取结果"; + else if (!sourceReady) button.title = "请先提取并启用 Excel 报表"; else if (!state.companyReportsReady) button.title = "公司渠道明细服务尚未就绪"; + else if (monthFuture) button.title = "未来报表月份暂不可生成"; + else if (!periodComplete) button.title = `周期尚未结束;可按当前已入库数据生成,周期结束时间为曼谷 ${completeDate} 00:00`; else button.removeAttribute("title"); }); - const availability = $("#company-period-availability"); - availability.classList.toggle("has-locked-periods", locked.length > 0); - if (!monthValid) { - availability.textContent = "请选择有效月份,系统会按泰国曼谷时间核对期间是否已经闭合。"; - } else if (locked.length) { - availability.textContent = `尚未开放:${locked.map((item) => `${companyPeriodRange(reportMonth, item.period)}(曼谷时间 ${item.releaseDate} 00:00 起)`).join("、")}。`; - } else { - availability.textContent = "所选月份的三个期间均已开放(泰国曼谷时间),可以生成或重跑。"; - } } function companyStateLabel(value) { @@ -536,12 +1473,31 @@ }[code] || "需复核处理结果"; } + function uniqueCompanyProblems(problems) { + const seen = new Set(); + return (problems || []).filter((item) => { + const key = `${item.kind || "problem"}|${item.code || ""}|${item.period || ""}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + } + + function companyProblemsForResult(result) { + const warnings = (result?.warnings || []).map((item) => ({ ...item, kind: "warning" })); + const errors = (result?.errors || []).map((item) => ({ ...item, kind: "error" })); + return uniqueCompanyProblems([...warnings, ...errors]); + } + function companySuccessCount(job) { return (job.company_results || []).filter((item) => item.status === "success").length; } function companyWarningCount(job) { - return (job.company_results || []).reduce((sum, item) => sum + (item.warnings || []).length, 0); + return (job.company_results || []).reduce( + (sum, item) => sum + companyProblemsForResult(item).filter((problem) => problem.kind === "warning").length, + 0, + ); } function companyPeriodCell(job, result, period) { @@ -565,7 +1521,6 @@ $("#company-report-result-rows").innerHTML = COMPANY_REPORT_NAMES.map((company) => { const result = byCompany.get(company); const warnings = result?.warnings || []; - const errors = result?.errors || []; const rowStyle = result?.status === "failed" ? "is-failed" : warnings.length ? "is-review" : ""; let resultStatus = isCompanyReportActive(job) ? "等待生成" : "无结果"; let resultStyle = isCompanyReportActive(job) ? "running" : "failed"; @@ -576,20 +1531,20 @@ resultStatus = "失败"; resultStyle = "failed"; } - const problems = [...warnings.map((item) => ({ ...item, kind: "warning" })), ...errors.map((item) => ({ ...item, kind: "error" }))]; + const problems = companyProblemsForResult(result); const problemHtml = problems.length - ? problems.map((item) => `${escapeHtml(companyProblemLabel(item.code))}${escapeHtml(item.period || "—")}`).join("") - : ''; + ? problems.map((item) => `${escapeHtml(companyProblemLabel(item.code))}${escapeHtml(item.period || "暂无")}`).join("") + : '暂无'; const download = result?.download_url?.startsWith("/api/company-reports/") ? `下载` - : `${isCompanyReportActive(job) ? "待生成" : "—"}`; + : `${isCompanyReportActive(job) ? "待生成" : "暂无"}`; return ` ${escapeHtml(company)} ${COMPANY_REPORT_PERIODS.map((period) => companyPeriodCell(job, result, period)).join("")} - ${result ? formatInteger(result.row_count) : "—"} + ${result ? formatInteger(result.row_count) : "暂无"} ${escapeHtml(resultStatus)}
${problemHtml}
- ${Number.isInteger(result?.version_no) ? `v${result.version_no}` : "—"} + ${formatDate(job.finished_at || job.created_at, true)} ${download} `; }).join(""); @@ -599,9 +1554,14 @@ state.companyReportCurrentJob = job; $("#company-report-job-panel").hidden = false; const terminal = ["succeeded", "partial_failure", "failed"].includes(job.state); - $("#company-report-job-kicker").textContent = terminal ? "TASK FINISHED" : "TASK STATUS"; + const kicker = $("#company-report-job-kicker"); + kicker.textContent = "TASK STATUS"; + kicker.hidden = terminal; $("#company-report-job-title").textContent = `${monthLabel(job.report_month)} · ${companyCutoffLabel(job)}`; - $("#company-report-job-message").textContent = job.message || "正在读取任务状态。"; + const message = $("#company-report-job-message"); + const messageText = job.state === "failed" && !job.failure_code ? "" : (job.message || ""); + message.textContent = messageText; + message.hidden = !messageText; const status = $("#company-report-job-state"); status.className = `status-chip ${companyStateStyle(job.state)}`; status.textContent = companyStateLabel(job.state); @@ -612,18 +1572,18 @@ $("#company-report-progress-track").setAttribute("aria-valuenow", String(percent)); $("#company-report-progress-bar").style.transform = `scaleX(${percent / 100})`; $("#company-report-job-month").textContent = monthLabel(job.report_month); - $("#company-report-job-as-of").textContent = job.as_of_date || "—"; + $("#company-report-job-as-of").textContent = job.as_of_date || "暂无"; $("#company-report-job-duration").textContent = formatDurationSeconds(job.duration_seconds); $("#company-report-warning-count").textContent = `${companyWarningCount(job)} 项`; $("#company-result-month-end-header").textContent = companyPeriodRange(job.report_month, "21-month-end"); renderCompanyResultRows(job); localStorage.setItem("arr:last-company-report-job", job.job_id); updateCompanyReportControls(); + updateCompanySourceUploadControls(); } function renderCompanyReportHistory(jobs) { state.companyReportJobs = jobs; - $("#company-history-count").textContent = `${jobs.length} 条记录`; const body = $("#company-history-rows"); if (!jobs.length) { body.innerHTML = '该月份还没有公司渠道明细任务'; @@ -631,7 +1591,7 @@ } body.innerHTML = jobs.map((job) => ` ${formatDate(job.created_at, true)} - ${escapeHtml(job.report_month || "—")} + ${escapeHtml(job.report_month || "暂无")} ${escapeHtml(companyCutoffLabel(job))} ${escapeHtml(companyStateLabel(job.state))} ${companySuccessCount(job)} / 5 @@ -642,22 +1602,43 @@ } async function loadCompanyReportHistory(restore = false) { + if (state.companyHistoryLoading) return; if (!state.companyReportsReady) { + state.companyReportsOffset = 0; + state.companyReportsTotal = 0; renderCompanyReportHistory([]); + renderPagination("company", 0, 0); return; } + state.companyHistoryLoading = true; + renderPagination("company", state.companyReportsTotal, state.companyReportsOffset, true); const month = $("#company-report-month").value || localMonth(); try { - const jobs = await api(`/api/company-reports/jobs?month=${encodeURIComponent(month)}&limit=100`); - renderCompanyReportHistory(jobs); + const encodedMonth = encodeURIComponent(month); + let page = readPage(await api( + `/api/company-reports/jobs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.companyReportsOffset}`, + { returnEnvelope: true }, + )); + if (!page.items.length && page.total > 0 && state.companyReportsOffset >= page.total) { + state.companyReportsOffset = pageOffset(page.total, state.companyReportsOffset, -1); + page = readPage(await api( + `/api/company-reports/jobs?month=${encodedMonth}&limit=${HISTORY_PAGE_SIZE}&offset=${state.companyReportsOffset}`, + { returnEnvelope: true }, + )); + } + state.companyReportsTotal = page.total; + renderCompanyReportHistory(page.items); if (restore) { const remembered = localStorage.getItem("arr:last-company-report-job"); - const target = jobs.find((job) => job.job_id === remembered) || jobs.find((job) => ["queued", "running"].includes(job.state)); + const target = page.items.find((job) => job.job_id === remembered) || page.items.find((job) => ["queued", "running"].includes(job.state)); if (target) await loadCompanyReportJob(target.job_id); } } catch (error) { $("#company-history-rows").innerHTML = '任务记录暂时无法读取'; if (restore) showToast(error.message, true); + } finally { + state.companyHistoryLoading = false; + renderPagination("company", state.companyReportsTotal, state.companyReportsOffset); } } @@ -678,46 +1659,119 @@ } } - async function startCompanyReport(period) { - if (!state.companyReportsReady || isCompanyReportActive() || state.companyReportSubmittingPeriod) return; + function companyReportConfirmIsOpen() { + return Boolean($("#company-report-confirm-dialog")?.open); + } + + function setCompanyReportConfirmBusy(busy) { + const dialog = $("#company-report-confirm-dialog"); + const cancel = $("#company-report-confirm-cancel"); + const submit = $("#company-report-confirm-submit"); + dialog.setAttribute("aria-busy", String(busy)); + cancel.disabled = busy; + submit.disabled = busy; + submit.textContent = busy ? "正在生成" : "确认生成"; + } + + function resetCompanyReportConfirm(restoreFocus = true) { + const request = state.companyReportConfirmRequest; + state.companyReportConfirmRequest = null; + const dialog = $("#company-report-confirm-dialog"); + const error = $("#company-report-confirm-error"); + dialog.setAttribute("aria-busy", "false"); + error.textContent = ""; + error.hidden = true; + if (restoreFocus && request?.trigger?.isConnected && !request.trigger.disabled) { + window.requestAnimationFrame(() => request.trigger.focus()); + } + updateCompanyReportControls(); + } + + function closeCompanyReportConfirm({ force = false, restoreFocus = true } = {}) { + if (state.companyReportSubmittingPeriod && !force) return; + const dialog = $("#company-report-confirm-dialog"); + if (state.companyReportConfirmRequest) resetCompanyReportConfirm(restoreFocus); + if (dialog.open && typeof dialog.close === "function") dialog.close(); + else dialog.removeAttribute("open"); + } + + function openCompanyReportConfirm({ period, trigger }) { + if (!state.companyReportsReady || !state.companySource || isCompanyReportActive() || state.companyReportSubmittingPeriod || companyReportConfirmIsOpen()) return; const reportMonth = $("#company-report-month").value; - if (!reportMonth) return; - const confirmed = window.confirm(`${monthLabel(reportMonth)} ${companyPeriodRange(reportMonth, period)}:将一次生成五家公司的正式 Excel,是否继续?`); - if (!confirmed) return; + if (!reportMonth || reportMonth > localMonth()) return; + const dialog = $("#company-report-confirm-dialog"); + state.companyReportConfirmRequest = { period, reportMonth, trigger }; + $("#company-report-confirm-description").textContent = `${monthLabel(reportMonth)},${companyPeriodRange(reportMonth, period)}`; + const completeDate = companyPeriodCompleteDate(reportMonth, period); + const periodComplete = Boolean(completeDate) && localDate() >= completeDate; + $("#company-report-confirm-note").textContent = periodComplete + ? "将一次生成五家公司的正式 Excel。" + : "周期尚未结束,将按当前已入库数据生成;后续新数据不会自动补入,可再次生成新版。"; + setCompanyReportConfirmBusy(false); + updateCompanyReportControls(); + if (typeof dialog.showModal === "function") dialog.showModal(); + else dialog.setAttribute("open", ""); + window.requestAnimationFrame(() => $("#company-report-confirm-cancel").focus()); + } + + async function confirmCompanyReportAction() { + const request = state.companyReportConfirmRequest; + if (!request || state.companyReportSubmittingPeriod) return; setCompanyReportError(); - state.companyReportSubmittingPeriod = period; + state.companyReportSubmittingPeriod = request.period; + setCompanyReportConfirmBusy(true); updateCompanyReportControls(); try { const created = await api("/api/company-reports/jobs", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ report_month: reportMonth, period }), + body: JSON.stringify({ report_month: request.reportMonth, period: request.period }), }); localStorage.setItem("arr:last-company-report-job", created.job_id); + state.companyReportsOffset = 0; + closeCompanyReportConfirm({ force: true, restoreFocus: false }); renderCompanyReportJob(created); $("#company-report-job-panel").scrollIntoView({ behavior: "smooth", block: "start" }); showToast("五家公司渠道明细任务已提交"); await loadCompanyReportJob(created.job_id); } catch (error) { - setCompanyReportError(error.message); - showToast(error.message, true); + const message = error.message || "公司渠道明细任务未能提交"; + const errorNode = $("#company-report-confirm-error"); + errorNode.textContent = message; + errorNode.hidden = false; + setCompanyReportConfirmBusy(false); + setCompanyReportError(message); + showToast(message, true); } finally { state.companyReportSubmittingPeriod = ""; updateCompanyReportControls(); } } + function startCompanyReport(period, trigger) { + openCompanyReportConfirm({ period, trigger }); + } + async function refreshCompanyReports() { setCompanyReportError(); await loadHealth(); - await loadCompanyReportHistory(true); + await Promise.all([loadCompanySource(true), loadCompanyDraft(true), loadCompanyReportHistory(true)]); showToast("公司渠道明细任务已刷新"); } + async function changeHistoryPage(totalKey, offsetKey, direction, loader) { + const nextOffset = pageOffset(state[totalKey], state[offsetKey], direction); + if (nextOffset === state[offsetKey]) return; + state[offsetKey] = nextOffset; + await loader(); + } + function bindEvents() { $$("[data-tab]").forEach((button) => button.addEventListener("click", () => activateTab(button.dataset.tab))); $("#xml-file").addEventListener("change", (event) => { + if (state.uploadInFlight) return; state.selectedFile = event.target.files?.[0] || null; + if (!state.uploadInFlight) resetUploadProgress(); $("#selected-file").textContent = state.selectedFile ? `${state.selectedFile.name} · ${formatInteger(state.selectedFile.size / 1024)} KB` : "尚未选择文件"; $("#upload-button").disabled = !state.selectedFile || !state.health.processing_ready; }); @@ -731,33 +1785,175 @@ dropzone.classList.remove("is-over"); })); dropzone.addEventListener("drop", (event) => { - if (!state.health.processing_ready) return; + if (!state.health.processing_ready || state.uploadInFlight) return; const file = event.dataTransfer?.files?.[0]; if (!file) return; state.selectedFile = file; + if (!state.uploadInFlight) resetUploadProgress(); $("#selected-file").textContent = `${file.name} · ${formatInteger(file.size / 1024)} KB`; $("#upload-button").disabled = false; }); $("#upload-button").addEventListener("click", handleUpload); + $("#company-excel-file").addEventListener("change", (event) => { + selectCompanySourceFile(event.target.files?.[0] || null); + }); + const companyDropzone = $("#company-excel-dropzone"); + ["dragenter", "dragover"].forEach((name) => companyDropzone.addEventListener(name, (event) => { + event.preventDefault(); + if (state.companySourceUploadReady && !state.companySourceUploading && !isCompanyReportActive()) { + companyDropzone.classList.add("is-over"); + } + })); + ["dragleave", "drop"].forEach((name) => companyDropzone.addEventListener(name, (event) => { + event.preventDefault(); + companyDropzone.classList.remove("is-over"); + })); + companyDropzone.addEventListener("drop", (event) => { + if (!state.companySourceUploadReady || state.companySourceUploading || isCompanyReportActive()) return; + selectCompanySourceFile(event.dataTransfer?.files?.[0] || null); + }); + $("#company-upload-button").addEventListener("click", handleCompanySourceUpload); + $("#company-review-body").addEventListener("click", (event) => { + const save = event.target.closest("[data-review-save]"); + if (save) { + saveCompanyReviewItem(save); + return; + } + const remove = event.target.closest("[data-review-delete]"); + if (remove) deleteCompanyReviewItem(remove); + }); + $("#company-review-body").addEventListener("change", (event) => { + const checkbox = event.target.closest("[data-review-select]"); + if (!checkbox || state.companyReviewMutating) return; + const itemId = Number(checkbox.value); + if (!Number.isInteger(itemId) || itemId <= 0) return; + if (checkbox.checked) state.companyReviewSelectedIds.add(itemId); + else state.companyReviewSelectedIds.delete(itemId); + syncCompanyReviewSelection(state.companySourceDraft?.items || [], false); + }); + $("#company-review-select-page").addEventListener("change", (event) => { + if (state.companyReviewMutating) return; + (state.companySourceDraft?.items || []).forEach((item) => { + const itemId = Number(item.item_id); + if (!Number.isInteger(itemId) || itemId <= 0) return; + if (event.currentTarget.checked) state.companyReviewSelectedIds.add(itemId); + else state.companyReviewSelectedIds.delete(itemId); + }); + syncCompanyReviewSelection(state.companySourceDraft?.items || [], false); + }); + $("#company-review-delete-selected").addEventListener("click", deleteSelectedCompanyReviewItems); + $("#company-review-discard").addEventListener("click", discardCompanyReviewDraft); + $("#company-review-activate").addEventListener("click", activateCompanyReviewDraft); + $("#company-review-prev").addEventListener("click", async () => { + clearCompanyReviewSelection(); + state.companyReviewOffset = Math.max(0, state.companyReviewOffset - COMPANY_REVIEW_PAGE_SIZE); + await loadCompanyDraft(true); + }); + $("#company-review-next").addEventListener("click", async () => { + clearCompanyReviewSelection(); + state.companyReviewOffset += COMPANY_REVIEW_PAGE_SIZE; + await loadCompanyDraft(true); + }); $("#refresh-jobs").addEventListener("click", () => loadJobs(true)); - $("#refresh-monthly").addEventListener("click", () => loadMonthly(true)); - $("#monthly-month").addEventListener("change", () => loadMonthly(true)); - $("#generate-monthly").addEventListener("click", generateMonthly); + $("#jobs-prev").addEventListener("click", () => + changeHistoryPage("jobsTotal", "jobsOffset", -1, () => loadJobs(true, false)) + ); + $("#jobs-next").addEventListener("click", () => + changeHistoryPage("jobsTotal", "jobsOffset", 1, () => loadJobs(true, false)) + ); + $("#monthly-prev").addEventListener("click", () => + changeHistoryPage("monthlyTotal", "monthlyOffset", -1, () => loadMonthly(true, false)) + ); + $("#monthly-next").addEventListener("click", () => + changeHistoryPage("monthlyTotal", "monthlyOffset", 1, () => loadMonthly(true, false)) + ); + $("#company-prev").addEventListener("click", () => + changeHistoryPage("companyReportsTotal", "companyReportsOffset", -1, () => loadCompanyReportHistory(false)) + ); + $("#company-next").addEventListener("click", () => + changeHistoryPage("companyReportsTotal", "companyReportsOffset", 1, () => loadCompanyReportHistory(false)) + ); + $("#jobs-body").addEventListener("click", (event) => { + if (event.target.closest("a, button")) return; + const row = event.target.closest("[data-job-id]"); + if (row) selectJob(row.dataset.jobId, { showLog: true }); + }); + $("#jobs-body").addEventListener("keydown", (event) => { + if (!["Enter", " "].includes(event.key)) return; + const row = event.target.closest("[data-job-id]"); + if (!row) return; + event.preventDefault(); + selectJob(row.dataset.jobId, { showLog: true }); + }); + $("#task-log-trigger").addEventListener("click", openTaskLog); + $("#close-task-log").addEventListener("click", closeTaskLog); + $("#task-log-dialog").addEventListener("close", clearTracePoll); + $("#task-log-dialog").addEventListener("click", (event) => { + if (event.target === event.currentTarget) closeTaskLog(); + }); + $("#company-review-confirm-cancel").addEventListener("click", () => closeCompanyReviewConfirm()); + $("#company-review-confirm-submit").addEventListener("click", confirmCompanyReviewAction); + $("#company-review-confirm-dialog").addEventListener("cancel", (event) => { + if (state.companyReviewMutating) event.preventDefault(); + }); + $("#company-review-confirm-dialog").addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + closeCompanyReviewConfirm(); + }); + $("#company-review-confirm-dialog").addEventListener("close", () => { + if (state.companyReviewConfirmRequest) resetCompanyReviewConfirm(); + }); + $("#company-review-confirm-dialog").addEventListener("click", (event) => { + if (event.target === event.currentTarget) closeCompanyReviewConfirm(); + }); + $("#company-report-confirm-cancel").addEventListener("click", () => closeCompanyReportConfirm()); + $("#company-report-confirm-submit").addEventListener("click", confirmCompanyReportAction); + $("#company-report-confirm-dialog").addEventListener("cancel", (event) => { + if (state.companyReportSubmittingPeriod) event.preventDefault(); + }); + $("#company-report-confirm-dialog").addEventListener("keydown", (event) => { + if (event.key !== "Escape") return; + event.preventDefault(); + closeCompanyReportConfirm(); + }); + $("#company-report-confirm-dialog").addEventListener("close", () => { + if (state.companyReportConfirmRequest) resetCompanyReportConfirm(); + }); + $("#company-report-confirm-dialog").addEventListener("click", (event) => { + if (event.target === event.currentTarget) closeCompanyReportConfirm(); + }); + $("#refresh-trace").addEventListener("click", async () => { + await loadJobTrace(true); + showToast("任务日志已刷新"); + }); + $("#copy-trace").addEventListener("click", copyAllTraceLogs); + $("#logout-button").addEventListener("click", logout); $("#bi-month").addEventListener("change", () => loadAnalytics(true)); $("#refresh-company-reports").addEventListener("click", refreshCompanyReports); $("#company-report-month").addEventListener("change", async () => { state.companyReportCurrentJob = null; + state.companyReportsOffset = 0; + state.companyReportsTotal = 0; $("#company-report-job-panel").hidden = true; updateCompanyReportControls(); + updateCompanySourceUploadControls(); await loadCompanyReportHistory(false); }); - $$('[data-company-period]').forEach((button) => button.addEventListener("click", () => startCompanyReport(button.dataset.companyPeriod))); + $$('[data-company-period]').forEach((button) => button.addEventListener("click", () => startCompanyReport(button.dataset.companyPeriod, button))); + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + clearTracePoll(); + clearMonthlyPoll(); + } else { + scheduleTracePoll(250); + if ($("#panel-monthly")?.classList.contains("is-active")) loadMonthly(false, true); + } + }); window.addEventListener("hashchange", () => activateTab(location.hash.slice(1), false)); } async function boot() { - $("#monthly-month").value = localMonth(); - $("#monthly-as-of").value = localDate(); $("#company-report-month").value = localMonth(); populateMonths([]); bindEvents(); @@ -766,9 +1962,17 @@ await initSession(); } catch (_) { showToast("页面会话初始化失败", true); + return; } await loadHealth(); - await Promise.all([loadJobs(), loadMonthly(), loadMonthsAndAnalytics(), loadCompanyReportHistory(location.hash.slice(1) === "usage")]); + await Promise.all([ + loadJobs(), + loadMonthly(), + loadMonthsAndAnalytics(), + loadCompanySource(), + loadCompanyDraft(), + loadCompanyReportHistory(location.hash.slice(1) === "usage"), + ]); } boot(); diff --git a/arr_web/static/h5.css b/arr_web/static/h5.css index 4f1dd28..5ac022e 100644 --- a/arr_web/static/h5.css +++ b/arr_web/static/h5.css @@ -14,15 +14,15 @@ body { margin: 0; min-width: 320px; color: var(--ink); background: var(--bg); fo button, select { font: inherit; } .mobile-header { height: 58px; padding: env(safe-area-inset-top) 18px 0; display: flex; align-items: center; justify-content: space-between; color: #fff; background: #172033; } -.brand { display: flex; align-items: center; gap: 9px; font-size: 17px; letter-spacing: -.03em; } -.brand > span { position: relative; width: 23px; height: 23px; } -.brand i { position: absolute; width: 13px; height: 13px; border-radius: 3px; transform: rotate(45deg); } -.brand i:first-child { top: 1px; left: 1px; background: #fff; } -.brand i:last-child { right: 1px; bottom: 1px; background: #5b8cff; } +.brand { display: flex; align-items: center; font-size: 17px; font-weight: 750; letter-spacing: -.03em; } +.brand-name { display: block; } +.mobile-header-actions { display: flex; align-items: center; gap: 11px; } .connection { display: inline-flex; align-items: center; gap: 7px; color: #c8d0dd; font-size: 11px; } .connection i { width: 7px; height: 7px; border-radius: 50%; background: #f79009; } .connection.ready i { background: #35c895; } .connection.error i { background: #f97066; } +.mobile-logout { min-height: 32px; padding: 0 10px; border: 1px solid rgba(255, 255, 255, .18); border-radius: 8px; color: #e8edf5; background: rgba(255, 255, 255, .07); font-size: 10px; font-weight: 700; cursor: pointer; } +.mobile-logout:disabled { cursor: wait; opacity: .5; } main { width: min(100% - 24px, 620px); margin: 0 auto; padding: 23px 0 calc(34px + env(safe-area-inset-bottom)); } .hero { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 4px 19px; } diff --git a/arr_web/static/h5.html b/arr_web/static/h5.html index fc3709f..ce1f297 100644 --- a/arr_web/static/h5.html +++ b/arr_web/static/h5.html @@ -10,8 +10,11 @@
-
ARR
- 正在连接 +
ARR Report
+
+ 正在连接 + +
@@ -41,7 +44,7 @@
-

ROOMS SOLD

渠道 × 房型售出房数

+

渠道 × 房型售出房数

正在读取数据…

diff --git a/arr_web/static/h5.js b/arr_web/static/h5.js index fc6b31a..06426e8 100644 --- a/arr_web/static/h5.js +++ b/arr_web/static/h5.js @@ -4,6 +4,7 @@ const colors = ["#2563eb", "#0f9f6e", "#7a5af8", "#f79009", "#06aed5", "#e0528d", "#64748b", "#84cc16"]; const BUSINESS_TIME_ZONE = "Asia/Bangkok"; const $ = (selector) => document.querySelector(selector); + let csrf = ""; function escapeHtml(value) { return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); @@ -28,8 +29,18 @@ return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY", maximumFractionDigits: 0 }).format(amount); } - async function api(path) { - const response = await fetch(path, { credentials: "same-origin" }); + function redirectToLogin() { + window.location.replace("/login?next=%2Fh5"); + } + + async function api(path, options = {}) { + const headers = new Headers(options.headers || {}); + if (options.method && options.method !== "GET") headers.set("X-ARR-CSRF", csrf); + const response = await fetch(path, { ...options, headers, credentials: "same-origin" }); + if (response.status === 401) { + redirectToLogin(); + throw new Error("登录状态已失效"); + } const payload = await response.json(); if (!response.ok || !payload.ok) throw new Error(payload?.error?.message || "数据读取失败"); return payload.data; @@ -107,6 +118,28 @@ async function boot() { const connection = $("#h5-connection"); + try { + const session = await api("/api/session"); + csrf = session.csrf_token; + const logout = $("#h5-logout"); + if (session.username) { + logout.hidden = false; + logout.disabled = false; + logout.title = `${session.username} · 退出登录`; + logout.addEventListener("click", async () => { + logout.disabled = true; + try { + await api("/api/logout", { method: "POST" }); + window.location.replace("/login"); + } catch (error) { + notify(error.message || "退出登录失败,请重试"); + logout.disabled = false; + } + }); + } + } catch (_) { + return; + } try { const health = await api("/api/health"); connection.classList.add(health.database_ready ? "ready" : "error"); diff --git a/arr_web/static/index.html b/arr_web/static/index.html index a65654a..6740d1c 100644 --- a/arr_web/static/index.html +++ b/arr_web/static/index.html @@ -13,12 +13,12 @@
- - ARRREPORT + ARR Report
正在连接数据服务 - 手机看板 + +
@@ -26,7 +26,7 @@