# Review guidelines:

You are acting as a reviewer for a proposed code change made by another engineer.

Below are some default guidelines for determining whether the original author would appreciate the issue being flagged.

These are not the final word in determining whether an issue is a bug. In many cases, you will encounter other, more specific guidelines. These may be present elsewhere in a developer message, a user message, a file, or even elsewhere in this system message.
Those guidelines should be considered to override these general instructions.

Here are the general guidelines for determining whether something is a bug and should be flagged.

1. It meaningfully impacts the accuracy, performance, security, or maintainability of the code.
2. The bug is discrete and actionable (i.e. not a general issue with the codebase or a combination of multiple issues).
3. Fixing the bug does not demand a level of rigor that is not present in the rest of the codebase (e.g. one doesn't need very detailed comments and input validation in a repository of one-off scripts in personal projects)
4. The bug was introduced in the commit (pre-existing bugs should not be flagged).
5. The author of the original PR would likely fix the issue if they were made aware of it.
6. The bug does not rely on unstated assumptions about the codebase or author's intent.
7. It is not enough to speculate that a change may disrupt another part of the codebase, to be considered a bug, one must identify the other parts of the code that are provably affected.
8. The bug is clearly not just an intentional change by the original author.

When flagging a bug, you will also provide an accompanying comment. Once again, these guidelines are not the final word on how to construct a comment -- defer to any subsequent guidelines that you encounter.

1. The comment should be clear about why the issue is a bug.
2. The comment should appropriately communicate the severity of the issue. It should not claim that an issue is more severe than it actually is.
3. The comment should be brief. The body should be at most 1 paragraph. It should not introduce line breaks within the natural language flow unless it is necessary for the code fragment.
4. The comment should not include any chunks of code longer than 3 lines. Any code chunks should be wrapped in markdown inline code tags or a code block.
5. The comment should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors.
6. The comment's tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer.
7. The comment should be written such that the original author can immediately grasp the idea without close reading.
8. The comment should avoid excessive flattery and comments that are not helpful to the original author. The comment should avoid phrasing like "Great job ...", "Thanks for ...".

Below are some more detailed guidelines that you should apply to this specific review.

HOW MANY FINDINGS TO RETURN:

Output all findings that the original author would fix if they knew about it. If there is no finding that a person would definitely love to see and fix, prefer outputting no findings. Do not stop at the first qualifying finding. Continue until you've listed every qualifying finding.

GUIDELINES:

- Ignore trivial style unless it obscures meaning or violates documented standards.
- Use one comment per distinct issue (or a multi-line range if necessary).
- Use ```suggestion blocks ONLY for concrete replacement code (minimal lines; no commentary inside the block).
- In every ```suggestion block, preserve the exact leading whitespace of the replaced lines (spaces vs tabs, number of spaces).
- Do NOT introduce or remove outer indentation levels unless that is the actual fix.

The comments will be presented in the code review as inline comments. You should avoid providing unnecessary location details in the comment body. Always keep the line range as short as possible for interpreting the issue. Avoid ranges longer than 5–10 lines; instead, choose the most suitable subrange that pinpoints the problem.

At the beginning of the finding title, tag the bug with priority level. For example "[P1] Un-padding slices along wrong tensor dimensions". [P0] – Drop everything to fix.  Blocking release, operations, or major usage. Only use for universal issues that do not depend on any assumptions about the inputs. · [P1] – Urgent. Should be addressed in the next cycle · [P2] – Normal. To be fixed eventually · [P3] – Low. Nice to have.

Additionally, include a numeric priority field in the JSON output for each finding: set "priority" to 0 for P0, 1 for P1, 2 for P2, or 3 for P3. If a priority cannot be determined, omit the field or use null.

At the end of your findings, output an "overall correctness" verdict of whether or not the patch should be considered "correct".
Correct implies that existing code and tests will not break, and the patch is free of bugs and other blocking issues.
Ignore non-blocking issues such as style, formatting, typos, documentation, and other nits.

FORMATTING GUIDELINES:
The finding description should be one paragraph.

OUTPUT FORMAT:

## Output schema  — MUST MATCH *exactly*

```json
{
  "findings": [
    {
      "title": "<≤ 80 chars, imperative>",
      "body": "<valid Markdown explaining *why* this is a problem; cite files/lines/functions>",
      "confidence_score": <float 0.0-1.0>,
      "priority": <int 0-3, optional>,
      "code_location": {
        "absolute_file_path": "<file path>",
        "line_range": {"start": <int>, "end": <int>}
      }
    }
  ],
  "overall_correctness": "patch is correct" | "patch is incorrect",
  "overall_explanation": "<1-3 sentence explanation justifying the overall_correctness verdict>",
  "overall_confidence_score": <float 0.0-1.0>
}
```

* **Do not** wrap the JSON in markdown fences or extra prose.
* The code_location field is required and must include absolute_file_path and line_range.
* Line ranges must be as short as possible for interpreting the issue (avoid ranges over 5–10 lines; pick the most suitable subrange).
* The code_location should overlap with the diff.
* Do not generate a PR fix.


<permissions instructions>
Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `workspace-write`: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval. Network access is restricted.
Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected.
 The writable roots are `/home/agent/.hermes/worktrees/FinanceManager-hotfix-sprint13-global-portfolio-aggregation`, `/tmp`.
</permissions instructions>

<apps_instructions>
## Apps (Connectors)
Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connector_id})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.
An app is equivalent to a set of MCP tools within the `codex_apps` MCP.
An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.
Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps.
</apps_instructions>

<skills_instructions>
## Skills
A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.
### Available skills
- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing image, or derive visual variants from references, and the output should be a bitmap asset rather than repo-native code or vector. Do not use when the task is better handled by editing existing SVG/vector/code-native assets, extending an established icon or logo system, or building the visual directly in HTML/CSS/canvas. (file: /home/agent/.codex/skills/.system/imagegen/SKILL.md)
- openai-docs: Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt-upgrade guidance; use OpenAI docs MCP tools for non-Codex docs questions, use the Codex manual helper first for broad Codex self-knowledge, and restrict fallback browsing to official OpenAI domains. (file: /home/agent/.codex/skills/.system/openai-docs/SKILL.md)
- plugin-creator: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, add optional plugin structure, generate or update marketplace entries for plugin ordering and availability metadata, or update an existing local plugin during development with the CLI-driven cachebuster and reinstall flow. (file: /home/agent/.codex/skills/.system/plugin-creator/SKILL.md)
- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: /home/agent/.codex/skills/.system/skill-creator/SKILL.md)
- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: /home/agent/.codex/skills/.system/skill-installer/SKILL.md)
- data-analytics:analyze-data-quality: Assess whether structured data, query results, dashboards, or analytical evidence are trustworthy enough to use. Use when the task is to check data quality, reconcile conflicting sources or metric definitions, or decide whether evidence is safe to cite. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/analyze-data-quality/SKILL.md)
- data-analytics:build-dashboard: Build source-backed dashboards for monitoring performance, exploring drivers, or acting on product and business metrics. Use when the task needs a dashboard, scorecard, or monitoring view with clear metrics, filters, source definitions, and QA. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/build-dashboard/SKILL.md)
- data-analytics:build-report: Build polished analytical reports for executive, product, business, or technical audiences. Use when the task needs a durable answer-first narrative with evidence-backed findings, visuals or tables, caveats, and source context. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/build-report/SKILL.md)
- data-analytics:create-data-context: Create, update, inspect, or repair Data Analytics semantic layers. Use when the user asks to save data context or create a semantic layer that future Data Analytics work can inspect and cite. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/create-data-context/SKILL.md)
- data-analytics:design-kpis: Design KPI frameworks, metric definitions, targets, guardrails, and measurement plans for product or business decisions. Use when success metrics, drivers, guardrails, targets, or the measurement approach need to be defined or improved. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/design-kpis/SKILL.md)
- data-analytics:gather-business-context: Gather business context from connected or provided sources so downstream analysis starts with the right framing. Use when an analytical question depends on missing context, such as what a metric means, what changed recently, or which sources should be checked. If the same prompt asks for diagnosis, recommendation, or a deliverable, gather context first and continue to the focused skill. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/gather-business-context/SKILL.md)
- data-analytics:index: Route Data Analytics plugin-level requests and broad analytics work to the right focused workflow. Use when Data Analytics is at-mentioned, or for analytics requests involving data, metrics, dashboards, reports, charts, notebooks, spreadsheets, KPIs, market sizing, or semantic layers. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/index/SKILL.md)
- data-analytics:jupyter-notebooks: Create, edit, or validate reproducible SQL or Python notebooks. Use for notebooks, SQL/Python scratchpads, reproducible exploration, audit trails, or runnable companions where the analysis should be reviewable or rerunnable. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/jupyter-notebooks/SKILL.md)
- data-analytics:kpi-reporting: Prepare KPI readouts, scorecards, WBR/MBR/QBR updates, and executive summaries from quantitative business or product metrics; use when the task is to report status, compare against targets, explain validated drivers, and state operating implications. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/kpi-reporting/SKILL.md)
- data-analytics:market-sizing: Estimate market, segment, or opportunity size with transparent assumptions and uncertainty. Use for TAM/SAM/SOM, sizing scenarios, or comparing the scale of possible opportunities. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/market-sizing/SKILL.md)
- data-analytics:metric-diagnostics: Diagnose why a metric changed or differs from expectation. Use when the task is to identify likely drivers of a metric movement, anomaly, gap, or discrepancy. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/metric-diagnostics/SKILL.md)
- data-analytics:product-business-analysis: Analyze product or business data to support a decision or recommendation. Use when a decision depends on metric-backed evidence, such as choosing a direction, prioritizing an opportunity, evaluating a change, segmenting users, sizing tradeoffs, or deciding what to do next. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/product-business-analysis/SKILL.md)
- data-analytics:publish-artifact-to-sites: Publish a validated Data Analytics report or dashboard artifact through Sites. Use automatically for durable artifacts in any positively identified Work Mode when the full Sites lifecycle is callable, or in ChatGPT Desktop outside Work Mode only after an explicit Sites request or acceptance of the optional post-handoff sharing offer. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/publish-artifact-to-sites/SKILL.md)
- data-analytics:validate-data: Validate whether an analysis is accurate, well-supported, and ready to share or use for a decision. Use when reviewing methodology, calculations, comparisons, visuals, caveats, or conclusions. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/validate-data/SKILL.md)
- data-analytics:visualize-data: Design, build, revise, or QA quantitative charts and figures. Use when an analytical answer needs visual judgment, whether for an inline answer, report, dashboard, notebook, or artifact. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/visualize-data/SKILL.md)
- github:gh-address-comments: Address actionable GitHub pull request review feedback. Use when the user wants to inspect unresolved review threads, requested changes, or inline review comments on a PR, then implement selected fixes. Use the GitHub app for PR metadata and flat comment reads, and use the bundled GraphQL script via `gh` whenever thread-level state, resolution status, or inline review context matters. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/github/0.1.8-2841cf9749ae/skills/gh-address-comments/SKILL.md)
- github:gh-fix-ci: Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions. Use the GitHub app from this plugin for PR metadata and patch context, and use `gh` for Actions check and log inspection before implementing any approved fix. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/github/0.1.8-2841cf9749ae/skills/gh-fix-ci/SKILL.md)
- github:github: Triage and orient GitHub repository, pull request, and issue work through the connected GitHub app. Use when the user asks for general GitHub help, wants PR or issue summaries, or needs repository context before choosing a more specific GitHub workflow. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/github/0.1.8-2841cf9749ae/skills/github/SKILL.md)
- github:yeet: Publish local changes to GitHub by confirming scope, committing intentionally, pushing the branch, and opening a draft PR through the GitHub app from this plugin, with `gh` used only as a fallback where connector coverage is insufficient. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/github/0.1.8-2841cf9749ae/skills/yeet/SKILL.md)
- gmail:gmail: Manage Gmail inbox triage, mailbox search, thread summaries, action extraction, reply drafting, and email forwarding through connected Gmail data. Use when the user wants to inspect a mailbox or thread, search email with Gmail query syntax, summarize messages, extract decisions and follow-ups, prepare replies or forwarded messages, or organize messages with explicit confirmation before send, archive, delete, or label actions. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/gmail/0.1.5/skills/gmail/SKILL.md)
- gmail:gmail-inbox-triage: Triage a Gmail inbox into actionable buckets such as urgent, needs reply soon, waiting, and FYI using connected Gmail data. Use when the user asks to triage the inbox, rank what needs attention, find what still needs a reply, or separate important mail from noise. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/gmail/0.1.5/skills/gmail-inbox-triage/SKILL.md)
- google-calendar:google-calendar: Manage scheduling and conflicts in connected Google Calendar data. Use when the user wants to inspect calendars, compare availability, review conflicts, find a meeting room, review event notes or attachments, add or adjust reminders, place temporary holds, or draft exact create, update, reschedule, or cancel changes with timezone-aware details. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-calendar/1.2.4/skills/google-calendar/SKILL.md)
- google-calendar:google-calendar-daily-brief: Build polished one-day Google Calendar briefs. Use when the user asks for today, tomorrow, or a specific date summary with an agenda, conflict flags, free windows, remaining-meeting readouts, or a calendar brief, and the Google Calendar connector is available. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-calendar/1.2.4/skills/google-calendar-daily-brief/SKILL.md)
- google-calendar:google-calendar-free-up-time: Find ways to open up meaningful free time in a connected Google Calendar. Use when the user wants to clear up their day, make room for focus time, create a longer uninterrupted block, or see the smallest set of calendar changes that would give time back. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-calendar/1.2.4/skills/google-calendar-free-up-time/SKILL.md)
- google-calendar:google-calendar-group-scheduler: Find and rank good meeting times for multiple people using connected Google Calendar data. Use when the user wants to schedule a group meeting, compare candidate slots across several attendees, find the best compromise time, or add a room check after narrowing the attendee-compatible options. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-calendar/1.2.4/skills/google-calendar-group-scheduler/SKILL.md)
- google-calendar:google-calendar-meeting-prep: Build a practical meeting prep brief from a connected Google Calendar event and its nearby context. Use when the user wants to prepare for an upcoming meeting, understand what to read beforehand, pull in linked notes or docs, or get a concise brief on what the meeting appears to require. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-calendar/1.2.4/skills/google-calendar-meeting-prep/SKILL.md)
- google-drive:google-docs: Prompt- and template-complete Google Docs creation and editing with explicit-instruction-authoritative structural preservation, including semantic roles, relationships, comparison dimensions, and instructed extensions; full-topology native-copy routing; source-grounded per-tab adaptation for past/example references; style-preserving hyperlink and table edits; canonical smart-chip-first authoring for dates and relevant supported people or Google resources; a file-backed advisory trusted read before existing-document writes; automatic protected-control awareness; direct connector APIs by default; DOCX-first import only when no supplied Google Doc template/reference constrains the output; and checked-in code mode only for exact native dropdown mutation. Use when Codex must create, edit, fill, adapt, redesign, or verify Google Docs without overriding explicit user/template instructions, adding unrequested document scope, or carrying stale reference facts into a new deliverable. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-drive/0.1.10/skills/google-docs/SKILL.md)
- google-drive:google-drive: Use connected Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work. Use when the user wants to find, fetch, organize, share, export, copy, or delete Drive files, or summarize and edit Google Docs, Google Sheets, and Google Slides through one unified Google Drive plugin. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-drive/0.1.10/skills/google-drive/SKILL.md)
- google-drive:google-drive-comments: Write, reply to, and resolve Google Drive comments on Docs, Sheets, Slides, and Drive files with evidence-backed location context. Use when the user asks to leave comments, review a file with comments, respond to comment threads, or resolve Drive comments. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-drive/0.1.10/skills/google-drive-comments/SKILL.md)
- google-drive:google-sheets: Analyze and edit connected Google Sheets with range precision. Use when the user wants to create Google Sheets, find a spreadsheet, inspect tabs or ranges, search rows, plan formulas, create or repair charts, clean or restructure tables, write concise summaries, or make explicit cell-range updates. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-drive/0.1.10/skills/google-sheets/SKILL.md)
- google-drive:google-slides: Route Google Slides authoring requests and derive a design system from a native template or reference deck. Use this skill when the user provides an existing native Google Slides deck as a template, reference, or prior-period source, or asks to edit, update, repair, restyle, or clean up an existing native Google Slides deck. Use the Presentations skill instead for net-new presentation creation when no existing native Google Slides deck must be followed. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/google-drive/0.1.10/skills/google-slides/SKILL.md)
- investment-banking:investment-banking: Route Investment Banking workflows for banker-owned transaction, capital markets, valuation, diligence, buyer/investor targeting, pitch, process, and restructuring work. Use when the user is preparing, reviewing, or executing M&A, financing, sponsor, issuer, lender, restructuring, coverage, or deal-advisory work, including CIMs, teasers, pitch materials, buyer lists, process trackers, merger models, capital markets analysis, and banker-facing valuation or diligence outputs. Do not use for public-equity investment decisions, personal financial advice, legal advice, FP&A, or generic writing tasks with no transaction or banker-workflow context. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/investment-banking/0.1.29/skills/investment-banking/SKILL.md)
- public-equity-investing:public-equity-investing: Route Public Equity Investing workflows for listed-company and public-security research, earnings analysis, valuation, model updates, long/short pitches, catalysts, thesis tracking, ETF/index and constituent diligence, position sizing, hedging, dashboards, and investment memos. Use when the user is evaluating a public company, ticker, sector, portfolio position, event, earnings setup, or investment thesis through an investor lens, including what is priced in, what is mispriced, what proves or kills the thesis, and what to watch next. Do not use for private-company diligence, credit-first analysis, personal financial advice, generic company summaries, or generic document drafting with no public-equity investment context. (file: /home/agent/.codex/plugins/cache/openai-curated-remote/public-equity-investing/0.1.31/skills/public-equity-investing/SKILL.md)
### How to use skills
- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
- How to use a skill (progressive disclosure):
  1) After deciding to use a skill, the main agent must open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF.
  2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
  3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it.
  4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
  5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
- Coordination and sequencing:
  - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
  - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
- Context hygiene:
  - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets.
  - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
  - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue.
</skills_instructions>

<plugins_instructions>
## Plugins
A plugin is a local bundle of skills, MCP servers, and apps. Below is the list of plugins that are enabled and available in this session.
### Available plugins
- `Data Analytics`: Answer product and business questions with data
- `GitHub`: Inspect repositories, triage pull requests and issues, debug CI, and publish changes through a hybrid GitHub connector and CLI workflow.
- `Gmail`: Work with Gmail using the configured Gmail app connector.
- `Google Calendar`: Connect Google Calendar for scheduling, availability, daily briefs, and event management.
- `Google Drive`: Use Google Drive as the single entrypoint for Drive, Docs, Sheets, and Slides work.
- `Investment Banking`: Investment banking workflows for M&A, coverage, sponsors, capital markets, LevFin, restructuring, valuation, diligence, pitch materials, and deal execution.
- `Lovable`: Build apps and websites
- `Default templates`: OpenAI default document, presentation, and spreadsheet templates.
- `Public Equity Investing`: Public equity investing workflows for listed-company research, earnings analysis, valuation, model updates, long/short pitches, catalysts, thesis tracking, ETF/index and constituent diligence, sell-side research notes, risk sizing, hedging, dashboards, and investment memos.
### How to use plugins
- Discovery: The list above is the plugins available in this session.
- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list.
- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn.
- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task.
- Preference: When a relevant plugin is available, prefer using capabilities associated with that plugin over standalone capabilities that provide similar functionality.
- Missing/blocked: If the user requests a plugin that is not listed above, or the plugin does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback.
</plugins_instructions>


# AGENTS.md instructions for /home/agent/.hermes/worktrees/FinanceManager-hotfix-sprint13-global-portfolio-aggregation

<INSTRUCTIONS>
# AGENTS.md

## FinanceManager-2.0-Regeln

- Arbeite in kleinen vertikalen PRs; keine Big-Bang-Rewrites.
- Produktive Finanzdaten, Rohimporte, Datenbanken, Reports und Secrets bleiben außerhalb Git.
- Schreibrouten bleiben Preview → Confirm → Audit und HTTP-seitig fail-closed. Keine Orderausführung.
- Sol entscheidet über Architektur, Security, Auth, Datenmodell, Migrationen, Formeln und Advisor-/IPS-Semantik.
- Spark nur sequenziell für ein kleines Ergebnis (ca. drei Dateien), nie für Secrets, produktive Daten, Auth, Migrationen oder Finanzberechnungen; Sol prüft jeden Diff.
- Kein Push, Merge, Deployment und keine Secret-Rotation ohne ausdrückliche Freigabe.
- Vor Abschluss: `make PYTHON=.venv/bin/python verify` und `git diff --check` tatsächlich ausführen.
- Jeder PR erfüllt die Felder der PR-Vorlage und aktualisiert bei Entscheidungsänderungen die Baseline/ADR.

Details: `docs/finance-manager-2.0/sprint-0-baseline.md`.

</INSTRUCTIONS>

<environment_context>
  <cwd>/home/agent/.hermes/worktrees/FinanceManager-hotfix-sprint13-global-portfolio-aggregation</cwd>
  <shell>bash</shell>
  <current_date>2026-07-27</current_date>
  <timezone>Europe/Zurich</timezone>
  <filesystem><workspace_roots><root>/home/agent/.hermes/worktrees/FinanceManager-hotfix-sprint13-global-portfolio-aggregation</root></workspace_roots><permission_profile type="managed"><file_system type="restricted"><entry access="read"><special>:root</special></entry><entry access="write"><path>/home/agent/.hermes/worktrees/FinanceManager-hotfix-sprint13-global-portfolio-aggregation</path></entry><entry access="write"><special>:slash_tmp</special></entry><entry access="write"><special>:tmpdir</special></entry><entry access="read"><path>/home/agent/.hermes/worktrees/FinanceManager-hotfix-sprint13-global-portfolio-aggregation/.git</path></entry><entry access="read"><path>/home/agent/.hermes/worktrees/FinanceManager-hotfix-sprint13-global-portfolio-aggregation/.agents</path></entry><entry access="read"><path>/home/agent/.hermes/worktrees/FinanceManager-hotfix-sprint13-global-portfolio-aggregation/.codex</path></entry></file_system></permission_profile></filesystem>
</environment_context>


Summary: auto


Review the current code changes (staged, unstaged, and untracked files) and provide prioritized findings.


git status --short && git diff --stat && git diff --cached --stat


Output:
Chunk ID: c96ac5
Wall time: 0.0000 seconds
Process exited with code 0
Original token count: 294
Output:
 M src/jarvis_finance/dashboard/data.py
 M src/jarvis_finance/imports/postfinance_documents.py
 M src/jarvis_finance/ledger/cash.py
 M src/jarvis_finance/services/cash_service.py
 M src/jarvis_finance/services/equity_service.py
 M src/jarvis_finance/services/portfolio_analytics.py
 M src/jarvis_finance/services/portfolio_data.py
 M src/jarvis_finance/services/postfinance_service.py
 M tests/test_postfinance_sprint13.py
?? docs/sprint13-global-portfolio-aggregation-hotfix.md
?? src/jarvis_finance/services/portfolio_aggregation.py
 src/jarvis_finance/dashboard/data.py               | 122 +++++++--
 .../imports/postfinance_documents.py               |  36 ++-
 src/jarvis_finance/ledger/cash.py                  |  28 +-
 src/jarvis_finance/services/cash_service.py        |  66 ++++-
 src/jarvis_finance/services/equity_service.py      |  79 +++++-
 src/jarvis_finance/services/portfolio_analytics.py |  42 +++
 src/jarvis_finance/services/portfolio_data.py      |  60 +++--
 src/jarvis_finance/services/postfinance_service.py |  32 ++-
 tests/test_postfinance_sprint13.py                 | 281 +++++++++++++++++++++
 9 files changed, 652 insertions(+), 94 deletions(-)


git diff -- src/jarvis_finance/dashboard/data.py src/jarvis_finance/imports/postfinance_documents.py src/jarvis_finance/ledger/cash.py src/jarvis_finance/services/cash_service.py src/jarvis_finance/services/equity_service.py src/jarvis_finance/services/portfolio_analytics.py src/jarvis_finance/services/portfolio_data.py src/jarvis_finance/services/postfinance_service.py tests/test_postfinance_sprint13.py && sed -n '1,240p' src/jarvis_finance/services/portfolio_aggregation.py


Output:
Chunk ID: ca74e8
Wall time: 0.0001 seconds
Process exited with code 0
Original token count: 16677
Output:
Total output lines: 1357

diff --git a/src/jarvis_finance/dashboard/data.py b/src/jarvis_finance/dashboard/data.py
index d619e72..b0d6491 100644
--- a/src/jarvis_finance/dashboard/data.py
+++ b/src/jarvis_finance/dashboard/data.py
@@ -1,15 +1,20 @@
 from __future__ import annotations

 import json
-from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
+from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
 from pathlib import Path
 from sqlite3 import Connection

 from jarvis_finance.config.settings import Settings
 from jarvis_finance.crypto.holdings import calculate_crypto_holdings
-from jarvis_finance.ledger.cash import calculate_cash_balances
-from jarvis_finance.ledger.positions import calculate_positions
+from jarvis_finance.ledger.positions import Position, calculate_positions
 from jarvis_finance.market.providers import is_price_stale
+from jarvis_finance.services.portfolio_aggregation import (
+    latest_audited_market_values,
+    latest_official_postfinance_positions,
+    post_snapshot_postfinance_quantity_deltas,
+    postfinance_account_roles,
+)

 MONEY = Decimal("0.01")

@@ -533,27 +538,41 @@ def get_platform_overview(conn: Connection) -> list[dict[str, str]]:

 def get_cash_overview(conn: Connection) -> list[dict[str, str]]:
-    result = calculate_cash_balances(conn)
-    rows: list[dict[str, str]] = []
-    for (account_id, currency), bal in sorted(result.balances.items()):
-        row = conn.execute(
-            "SELECT a.account_name, p.name AS platform FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id WHERE a.account_id=?",
-            (account_id,),
-        ).fetchone()
-        rows.append({
-            "account_id": account_id,
-            "platform": row["platform"] if row else "",
-            "account_name": row["account_name"] if row else account_id,
-            "currency": currency,
-            "amount_original": dec_text(bal.amount_original, "0.01"),
-            "amount_chf": dec_text(bal.amount_chf, "0.01") if bal.amount_chf is not None else "",
-            "quality_status": bal.quality_status,
-        })
-    return rows
+    # One shared cash read model for the API, Command Center, allocation and reports.
+    # The local import avoids coupling the write-oriented cash service into module init.
+    from jarvis_finance.services.cash_service import list_cash_positions
+
+    return [
+        {
+            "account_id": position.account_id,
+            "platform": position.platform,
+            "account_name": position.account_label,
+            "currency": position.currency,
+            "amount_original": position.amount,
+            "amount_chf": position.used_value_chf,
+            "quality_status": "ok" if position.status != "Abgleich offen" else "partial",
+        }
+        for position in list_cash_positions(conn)
+    ]

 def get_positions(conn: Connection) -> list[dict[str, str]]:
     result = calculate_positions(conn, persist_alerts=False)
+    official = latest_official_postfinance_positions(conn)
+    post_snapshot_deltas = post_snapshot_postfinance_quantity_deltas(conn, official)
+    audited = latest_audited_market_values(conn)
+    roles = postfinance_account_roles(conn)
+    trading_cash_id = roles.get("etrading_cash")
+    official_instruments = {key[1] for key in official}
+    for key in list(result.positions):
+        if key[0] == trading_cash_id and key[1] in official_instruments:
+            # Instrument-bearing settlement events live on trading cash, while the
+            # authoritative holding belongs to the distinct depot role.
+            result.positions.pop(key)
+    for key, override in official.items():
+        result.positions.setdefault(
+            key, Position(account_id=override.account_id, instrument_id=override.instrument_id)
+        )
     rows: list[dict[str, str]] = []
     for (account_id, instrument_id), pos in sorted(result.positions.items()):
         row = conn.execute(
@@ -573,6 +592,51 @@ def get_positions(conn: Connection) -> list[dict[str, str]]:
             """,
             (instrument_id, account_id),
         ).fetchone()
+        override = official.get((account_id, instrument_id))
+        effective_quantity = (
+            override.quantity + post_snapshot_deltas.get(instrument_id, Decimal("0"))
+            if override
+            else pos.quantity
+        )
+        if override and effective_quantity <= 0:
+            continue
+        market_value = audited.get((account_id, instrument_id))
+        use_market_run = bool(
+            market_value
+            and (not override or market_value.as_of > override.snapshot_date)
+        )
+        if override and not use_market_run:
+            quantity = effective_quantity
+            market_price = override.market_price_original
+            market_value_chf = (
+                override.market_value_chf * effective_quantity / override.quantity
+                if override.quantity
+                else Decimal("0")
+            )
+            price_currency = override.price_currency
+            price_date = override.valuation_at
+            price_provider = "PostFinance official snapshot"
+            valuation_source = "postfinance_official_import"
+        elif use_market_run and market_value:
+            quantity = effective_quantity
+            market_price = market_value.close
+            market_value_chf = (
+                market_value.value_chf * effective_quantity / market_value.quantity
+                if market_value.quantity
+                else market_value.value_chf
+            )
+            price_currency = market_value.currency
+            price_date = market_value.as_of
+            price_provider = market_value.provider
+            valuation_source = "audited_market_run"
+        else:
+            quantity = pos.quantity
+            market_price = pos.market_price_original
+            market_value_chf = pos.market_value_chf
+            price_currency = row["latest_price_currency"] if row else ""
+            price_date = date_text(row["latest_price_date"] if row else "")
+            price_provider = row["latest_price_provider"] if row else ""
+            valuation_source = "canonical_ledger_market"
         rows.append({
             "account_id": account_id,
             "instrument_id": instrument_id,
@@ -582,20 +646,22 @@ def get_positions(conn: Connection) -> list[dict[str, str]]:
             "name": row["name"] if row else instrument_id,
             "ticker": row["ticker"] if row else "",
             "isin": row["isin"] if row else "",
-            "quantity": dec_text(pos.quantity),
+            "quantity": dec_text(quantity),
             "cost_basis_chf": dec_text(pos.cost_basis_chf, "0.01"),
-            "market_price_original": dec_text(pos.market_price_original, "0.01") if pos.market_price_original is not None else "",
-            "price_currency": row["latest_price_currency"] if row else "",
-            "price_date": date_text(row["latest_price_date"] if row else ""),
-            "price_provider": row["latest_price_provider"] if row else "",
-            "market_value_chf": dec_text(pos.market_value_chf, "0.01") if pos.market_value_chf is not None else "",
-            "price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
+            "market_price_original": dec_text(market_price, "0.01") if market_price is not None else "",
+            "price_currency": price_currency,
+            "price_date": price_date,
+            "price_provider": price_provider,
+            "market_value_chf": dec_text(market_value_chf, "0.01") if market_value_chf is not None else "",
+            "price_status": "ok" if market_price is not None else "missing_market_price",
+            "valuation_source": valuation_source,
+            "valuation_as_of": price_date,
             "provider_symbol_status": "mapped" if conn.execute("SELECT 1 FROM instrument_price_mappings WHERE instrument_id=? AND mapping_status IN ('mapped','confirmed','user_confirmed') AND provider_symbol IS NOT NULL LIMIT 1", (instrument_id,)).fetchone() else "missing_provider_symbol",
             "hedge_status": row["hedge_status"] if row else pos.hedge_status,
             "instrument_status": row["instrument_status"] if row else pos.instrument_status,
             "valuation_policy": row["valuation_policy"] if row else pos.valuation_policy,
             "fx_status": "ok" if "missing_fx" not in pos.quality_warnings else "missing_fx",
-            "market_price_status": "ok" if pos.market_price_original is not None else "missing_market_price",
+            "market_price_status": "ok" if market_price is not None else "missing_market_price",
             "valuation_timestamp_status": pos.valuation_timestamp_status,
             "corporate_action_status": row["corporate_action_status"] if row else pos.corporate_action_status,
             "valuation_status": pos.valuation_status,
diff --git a/src/jarvis_finance/imports/postfinance_documents.py b/src/jarvis_finance/imports/postfinance_documents.py
index b6e6703..8cfa6d0 100644
--- a/src/jarvis_finance/imports/postfinance_documents.py
+++ b/src/jarvis_finance/imports/postfinance_documents.py
@@ -7,14 +7,14 @@ without trusting extracted temporary files.

 from __future__ import annotations

-from dataclasses import dataclass
+import io
+import re
+import stat
+from dataclasses import dataclass, replace
 from datetime import datetime
 from decimal import Decimal, InvalidOperation
 from hashlib import sha256
-import io
 from pathlib import PurePosixPath
-import re
-import stat
 from zipfile import BadZipFile, ZipFile

 from pypdf import PdfReader
@@ -118,6 +118,33 @@ class PFBundle:
     reason_codes: tuple[str, ...]

+def _reconcile_cash_components(
+    cash: list[PFSnapshotCash], cash_total_chf: Decimal
+) -> list[PFSnapshotCash]:
+    """Allocate the official cash subtotal across rows whose displayed FX is rounded.
+
+    The final CHF-looking column in the source row is the whole portfolio's currency
+    exposure, not the currency-cash value. It must never be projected as cash.
+    """
+
+    raw = [item.amount_original * item.fx_rate_to_chf for item in cash]
+    raw_total = sum(raw, Decimal("0"))
+    if not raw or raw_total <= 0:
+        raise ValueError("PostFinance cash components cannot be reconciled")
+    money = Decimal("0.01")
+    allocated = [(value * cash_total_chf / raw_total).quantize(money) for value in raw]
+    residual = cash_total_chf.quantize(money) - sum(allocated, Decimal("0"))
+    if residual:
+        largest = max(range(len(raw)), key=raw.__getitem__)
+        allocated[largest] += residual
+    if sum(allocated, Decimal("0")) != cash_total_chf.quantize(money):
+        raise ValueError("PostFinance allocated cash components do not reconcile")
+    return [
+        replace(item, source_value_chf=value)
+        for item, value in zip(cash, allocated, strict=True)
+    ]
+
+
 def _hash(*parts: object) -> str:
     return sha256("|".join(str(p) for p in parts).encode()).hexdigest()

@@ -650,6 +677,7 @@ def _parse_snapshot(raw: bytes) -> PFSnapshot:
         raise ValueError("PostFinance account total does not reconcile")
     if abs(stock_total + etf_total - securities_total) > Decimal("0.05"):
         raise ValueError("PostFinance asset-class totals do not reconcile")
+    cash = _reconcile_cash_components(cash, cash_total)
     return PFSnapshot(
         sha256(raw).hexdigest(),
         page_count,
diff --git a/src/jarvis_finance/ledger/cash.py b/src/jarvis_finance/ledger/cash.py
index eb9f0c7..e4a894e 100644
--- a/src/jarvis_finance/ledger/cash.py
+++ b/src/jarvis_finance/ledger/cash.py
@@ -62,7 +62,9 @@ def _signed_cash_delta(row) -> tuple[Decimal, Decimal | None]:
     return original, chf

-def calculate_cash_balances(conn: Connection, *, as_of_date: str | None = None) -> CashCalculationResult:
+def calculate_cash_balances(
+    conn: Connection, *, as_of_date: str | None = None, persist_alerts: bool = True
+) -> CashCalculationResult:
     result = CashCalculationResult()
     params: tuple[object, ...] = ()
     where = "WHERE COALESCE(is_voided, 0) = 0"
@@ -93,17 +95,19 @@ def calculate_cash_balances(conn: Connection, *, as_of_date: str | None = None)
         if balance.amount_original < 0:
             msg = f"Negative cash balance for account={balance.account_id} currency={balance.currency}: {balance.amount_original}"
             result.negative_cash_warnings.append(msg)
-            create_alert(
-                conn,
-                priority="warnung",
-                category="cash",
-                entity_type="account",
-                entity_id=balance.account_id,
-                rule_id="negative_cash",
-                message=msg,
-                evidence={"currency": balance.currency, "amount_original": str(balance.amount_original)},
-            )
-    conn.commit()
+            if persist_alerts:
+                create_alert(
+                    conn,
+                    priority="warnung",
+                    category="cash",
+                    entity_type="account",
+                    entity_id=balance.account_id,
+                    rule_id="negative_cash",
+                    message=msg,
+                    evidence={"currency": balance.currency, "amount_original": str(balance.amount_original)},
+                )
+    if persist_alerts:
+        conn.commit()
     return result

diff --git a/src/jarvis_finance/services/cash_service.py b/src/jarvis_finance/services/cash_service.py
index 58edbd4..3a31fe5 100644
--- a/src/jarvis_finance/services/cash_service.py
+++ b/src/jarvis_finance/services/cash_service.py
@@ -1,8 +1,8 @@
 from __future__ import annotations

+import json
 from datetime import UTC, datetime
 from decimal import Decimal, InvalidOperation
-import json
 from sqlite3 import Connection
 from uuid import uuid4

@@ -19,7 +19,9 @@ from jarvis_finance.api.schemas.positions import (
     ConfirmResponse,
     PreviewResponse,
 )
+from jarvis_finance.ledger.cash import calculate_cash_balances
 from jarvis_finance.services.api_helpers import decimal_text
+from jarvis_finance.services.portfolio_aggregation import latest_official_postfinance_cash

 CANONICAL_CASH_ACCOUNTS = [
     {"platform": "Raiffeisen", "account_name": "Raiffeisen", "balance_mode": "csv_calculated"},
@@ -147,11 +149,24 @@ def _calculated_balance(conn: Connection, account_id: str) -> Decimal:
     anchor = _latest_snapshot(conn, account_id, "csv_anchor_balance")
     if anchor:
         return Decimal(str(anchor["amount_chf"])) + _cash_movements_after_anchor(conn, account_id, anchor["balance_date"])
-    row = conn.execute(
-        "SELECT COALESCE(SUM(CAST(amount_chf AS TEXT)), '0') AS amount FROM cash_balances WHERE account_id=?",
+    rows = conn.execute(
+        "SELECT amount_chf FROM cash_balances WHERE account_id=?",
         (account_id,),
-    ).fetchone()
-    return Decimal(str(row["amount"] or "0"))
+    ).fetchall()
+    if rows:
+        return sum((Decimal(str(row["amount_chf"])) for row in rows), Decimal("0"))
+    # Legacy/demo datasets can have a transaction ledger without materialized
+    # cash_balances. Keep the shared read model compatible without overriding an
+    # explicit snapshot projection.
+    ledger = calculate_cash_balances(conn, persist_alerts=False)
+    return sum(
+        (
+            balance.amount_chf
+            for (ledger_account_id, _currency), balance in ledger.balances.items()
+            if ledger_account_id == account_id and balance.amount_chf is not None
+        ),
+        Decimal("0"),
+    )

 def _last_imported_booking(conn: Connection, account_id: str) -> str | None:
@@ -164,13 +179,48 @@ def list_cash_positions(conn: Connection) -> list[CashPosition]:
         """
         SELECT a.account_id, a.account_name, a.account_type, a.currency, COALESCE(a.balance_mode,'manual') AS balance_mode, p.name AS platform
         FROM accounts a JOIN platforms p ON p.platform_id=a.platform_id
-        WHERE a.is_active=1 AND a.account_type='cash'
+        WHERE a.is_active=1
+          AND (
+              a.account_type='cash'
+              OR a.account_id IN (
+                  SELECT account_id FROM postfinance_account_roles
+                  WHERE role='etrading_cash'
+              )
+          )
         ORDER BY p.name, a.account_name
         """
     ).fetchall()
     positions: list[CashPosition] = []
+    official_components = latest_official_postfinance_cash(conn)
+    official_by_account: dict[str, list] = {}
+    for component in official_components:
+        official_by_account.setdefault(component.account_id, []).append(component)
     for row in rows:
         account_id = row["account_id"]
+        if account_id in official_by_account:
+            for component in official_by_account[account_id]:
+                positions.append(
+                    CashPosition(
+                        id=f"{account_id}:{component.currency}",
+                        account_id=account_id,
+                        platform=row["platform"],
+                        account_label=row["account_name"],
+                        account_type=row["account_type"],
+                        currency=component.currency,
+                        balance_mode="official_components",
+                        amount=decimal_text(component.amount_original, 2),
+                        amount_chf=decimal_text(component.amount_chf, 2),
+                        calculated_balance_chf=decimal_text(component.amount_chf, 2),
+                        manual_balance_chf=None,
+                        csv_anchor_balance_chf=None,
+                        used_value_chf=decimal_text(component.amount_chf, 2),
+                        difference_chf="0.00",
+                        last_manual_reconciliation=component.snapshot_date,
+                        last_imported_booking=_last_imported_booking(conn, account_id),
+                        status="Offiziell abgeglichen",
+                    )
+                )
+            continue
         manual = _latest_snapshot(conn, account_id, "manual_balance")
         anchor = _latest_snapshot(conn, account_id, "csv_anchor_balance")
         calculated = _calculated_balance(conn, account_id)
@@ -252,10 +302,10 @@ def confirm_cash_snapshot(conn: Connection, request: CashSnapshotConfirmRequest)

 def get_cash_position_detail(conn: Connection, position_id: str) -> CashPositionDetail:
-    account_id, _currency = position_id.split(":", 1)
-    position = next((p for p in get_cash_summary(conn).positions if p.account_id == account_id), None)
+    position = next((p for p in get_cash_summary(conn).positions if p.id == position_id), None)
     if not position:
         raise HTTPException(status_code=404, detail="Cash position not found")
+    account_id = position.account_id
     rows = conn.execute(
         """
         SELECT snapshot_id, snapshot_type, balance_date, amount_chf, source, COALESCE(note,'') AS note, audit_id
diff --git a/src/jarvis_finance/services/equity_service.py b/src/jarvis_finance/services/equity_service.py
index 614e76b..acb3fca 100644
--- a/src/jarvis_finance/services/equity_service.py
+++ b/src/jarvis_finance/services/equity_service.py
@@ -108,24 +108,57 @@ def list_equity_positions(conn: Connection) -> list[EquityPosition]:
         and row.get("instrument_status", "").lower() != "inactive"
     ]
     analysis, values, missing = _latest_analysis(conn)
-    use_cache_fallback = analysis is None
-    expected_keys = {(str(row.get("account_id") or ""), str(row.get("instrument_id") or "")) for row in source_rows}
-    complete = bool(analysis and analysis.get("quality_status") == "complete" and set(values) == expected_keys)
-    total = sum((Decimal(str(item.get("value_chf") or "0")) for item in values.values()), Decimal("0"))
+    effective_values: dict[tuple[str, str], Deci…6677 tokens truncated…tal placeholder',
+                  '2026-07-26T00:00:00Z','synthetic')""",
+        (CASH,),
+    )
+    conn.execute(
+        "INSERT INTO platforms(platform_id,name,platform_type,country,default_currency,is_active,created_at) VALUES('tw','True Wealth','wealth','CH','CHF',1,'2026-01-01')"
+    )
+    conn.execute(
+        """INSERT INTO accounts(account_id,platform_id,account_name,account_type,currency,
+               performance_included,is_active,created_at,portfolio_bucket)
+           VALUES('account_tw','tw','True Wealth','brokerage','CHF',1,1,'2026-01-01','truewealth')"""
+    )
+    conn.execute(
+        """INSERT INTO account_value_snapshots(snapshot_id,account_id,valuation_date,
+               total_value_chf,currency,source_type,quality_status,created_at,is_active)
+           VALUES('tw-value','account_tw','2026-07-27','152845.00','CHF',
+                  'synthetic_truewealth','complete','2026-07-27T00:00:00Z',1)"""
+    )
+    conn.commit()
+
+    payload = request()
+    preview = service.preview_postfinance_import(conn, payload)
+    service.confirm_postfinance_import(
+        conn,
+        {**payload, "preview_id": preview["preview_id"],
+         "confirmation_id": preview["confirmation_id"], "confirm": True},
+    )
+    before_gets = conn.total_changes
+    cash = get_cash_summary(conn)
+    equity = get_equity_summary(conn)
+    overview = get_overview(conn)
+    command = get_command_center_summary(conn)
+    reconciliation = build_portfolio_reconciliation(conn, as_of="2026-07-27")
+    rows = get_positions(conn)
+
+    assert conn.total_changes == before_gets
+    assert cash.cash_chf == "27684.07"
+    assert sum(Decimal(item.used_value_chf) for item in cash.positions if item.account_id == CASH) == Decimal("27684.07")
+    usd_position = next(item for item in cash.positions if item.account_id == CASH and item.currency == "USD")
+    assert get_cash_position_detail(conn, usd_position.id).currency == "USD"
+    assert equity.equity_value_chf == "293465.16"
+    assert equity.as_of == "2026-07-27T09:14:30+02:00"
+    assert sum(Decimal(row["market_value_chf"]) for row in rows if row["account_id"] == DEPOT) == Decimal("293465.16")
+    assert not [row for row in rows if row["account_id"] == CASH and row["asset_class"] in {"stock", "equity", "etf"}]
+    assert Decimal(cash.cash_chf) + Decimal(equity.equity_value_chf) == Decimal("321149.23")
+    assert overview.truewealth_value_chf == "152845.00"
+    assert command["cash_total_chf"] == "27684.07"
+    pf_reconciliation = next(
+        item for item in reconciliation["account_totals"]
+        if item["reported_total"] == "321149.23"
+    )
+    assert pf_reconciliation["derived_total"] == "321149.23"
+    assert pf_reconciliation["status"] == "matched"
+    assert conn.execute("SELECT amount_chf FROM cash_account_snapshots WHERE snapshot_id='legacy-total'").fetchone()[0] == "321149.23"
+    assert conn.execute("SELECT COUNT(*) FROM transactions WHERE internal_transfer_group_id IS NOT NULL").fetchone()[0] == 2
+
+    market_positions = []
+    for row, value in zip(
+        [row for row in rows if row["account_id"] == DEPOT],
+        split_total(Decimal("160000")) + split_total(Decimal("140000")),
+        strict=True,
+    ):
+        market_positions.append({
+            "account_id": DEPOT, "instrument_id": row["instrument_id"],
+            "quality_status": "fresh", "value_chf": str(value), "close": str(value),
+            "currency": "CHF", "provider": "synthetic-market",
+        })
+    conn.execute(
+        """INSERT INTO market_data_runs(run_id,source_key,as_of,input_fingerprint,status,
+               started_at,completed_at,price_total,price_stored,fx_total,fx_stored,
+               benchmark_total,benchmark_stored,valuation_stored,missing_instruments_json,
+               reason_codes_json)
+           VALUES('newer-run','synthetic','2026-07-28','fp','complete','2026-07-28T00:00:00Z',
+                  '2026-07-28T00:01:00Z',22,22,0,0,0,0,22,'[]','[]')"""
+    )
+    conn.execute(
+        """INSERT INTO portfolio_analysis_snapshots(analysis_snapshot_id,run_id,as_of,
+               base_currency,total_value_chf,price_coverage_pct,fx_coverage_pct,
+               benchmark_coverage_pct,quality_status,reason_codes_json,summary_json,created_at)
+           VALUES('newer-analysis','newer-run','2026-07-28','CHF','300000','100','100','0',
+                  'complete','[]',?,'2026-07-28T00:01:00Z')""",
+        (json.dumps({"positions": market_positions}),),
+    )
+    conn.commit()
+    newer = get_equity_summary(conn)
+    assert newer.equity_value_chf == "300000.00"
+    assert newer.as_of == "2026-07-28"
+    assert get_cash_summary(conn).cash_chf == "27684.07"
+    assert service.get_postfinance_summary(conn)["latest_snapshot"]["total_chf"] == "321149.23"
+
+    duplicate = service.preview_postfinance_import(conn, payload)
+    second = service.confirm_postfinance_import(
+        conn,
+        {**payload, "preview_id": duplicate["preview_id"],
+         "confirmation_id": duplicate["confirmation_id"], "confirm": True},
+    )
+    assert duplicate["duplicate"] is True and duplicate["conflict"] is False
+    assert second["idempotent"] is True
+    assert conn.execute("SELECT COUNT(*) FROM postfinance_import_batches").fetchone()[0] == 1
+
+
+def test_official_snapshot_resolvers_honor_historical_as_of_and_cutoff(monkeypatch, tmp_path):
+    conn = make_conn()
+    monkeypatch.setenv("JARVIS_FINANCE_RUNTIME_DIR", str(tmp_path))
+    monkeypatch.setattr(service, "parse_postfinance_bundle", lambda _zip, _overview: bundle())
+    payload = request()
+    preview = service.preview_postfinance_import(conn, payload)
+    service.confirm_postfinance_import(
+        conn,
+        {**payload, "preview_id": preview["preview_id"],
+         "confirmation_id": preview["confirmation_id"], "confirm": True},
+    )
+    original_cash_total = sum(
+        (item.amount_chf for item in latest_official_postfinance_cash(conn)), Decimal("0")
+    )
+    original_position_total = sum(
+        (
+            item.market_value_chf
+            for item in latest_official_postfinance_positions(conn).values()
+        ),
+        Decimal("0"),
+    )
+    conn.execute("UPDATE accounts SET account_type='checking' WHERE account_id=?", (CASH,))
+    assert sum(
+        (
+            Decimal(item.used_value_chf)
+            for item in get_cash_summary(conn).positions
+            if item.account_id == CASH
+        ),
+        Decimal("0"),
+    ) == original_cash_total
+
+    batch = dict(conn.execute("SELECT * FROM postfinance_import_batches LIMIT 1").fetchone())
+    batch.update(
+        batch_id="future-batch",
+        bundle_sha256=sha256(b"future-bundle").hexdigest(),
+        snapshot_at="2026-08-01T09:14:30+02:00",
+        confirmed_at="2026-08-01T10:00:00Z",
+    )
+    columns = list(batch)
+    conn.execute(
+        f"INSERT INTO postfinance_import_batches({','.join(columns)}) VALUES({','.join('?' for _ in columns)})",
+        [batch[column] for column in columns],
+    )
+    source_snapshot = dict(conn.execute("SELECT * FROM postfinance_snapshots LIMIT 1").fetchone())
+    source_snapshot.update(
+        snapshot_id="future-snapshot",
+        batch_id="future-batch",
+        snapshot_at="2026-08-01T09:14:30+02:00",
+        total_chf="1299.00",
+        securities_chf="300.00",
+        cash_chf="999.00",
+        stocks_chf="100.00",
+        etfs_chf="200.00",
+        component_total_chf="1299.00",
+        difference_chf="0.00",
+        created_at="2026-08-01T10:00:00Z",
+    )
+    columns = list(source_snapshot)
+    conn.execute(
+        f"INSERT INTO postfinance_snapshots({','.join(columns)}) VALUES({','.join('?' for _ in columns)})",
+        [source_snapshot[column] for column in columns],
+    )
+    conn.execute(
+        """INSERT INTO postfinance_snapshot_positions(
+               snapshot_position_id,snapshot_id,source_row_reference,instrument_id,asset_class,
+               quantity,provider_average_cost_original,provider_cost_total_original,
+               price_original,price_currency,value_chf,weight_pct,computed_cost_basis_original,
+               computed_cost_basis_status,provenance_json,created_at)
+           SELECT 'future-'||snapshot_position_id,'future-snapshot',source_row_reference,
+                  instrument_id,asset_class,quantity,provider_average_cost_original,
+                  provider_cost_total_original,price_original,price_currency,value_chf,weight_pct,
+                  computed_cost_basis_original,computed_cost_basis_status,provenance_json,
+                  '2026-08-01T10:00:00Z'
+           FROM postfinance_snapshot_positions WHERE snapshot_id<> 'future-snapshot'"""
+    )
+    conn.execute(
+        """INSERT INTO postfinance_snapshot_cash(
+               snapshot_cash_id,snapshot_id,currency,amount_original,fx_rate_to_chf,value_chf,created_at)
+           SELECT 'future-'||snapshot_cash_id,'future-snapshot',currency,amount_original,
+                  fx_rate_to_chf,value_chf,'2026-08-01T10:00:00Z'
+           FROM postfinance_snapshot_cash WHERE snapshot_id<> 'future-snapshot'"""
+    )
+    conn.commit()
+
+    assert sum((item.amount_chf for item in latest_official_postfinance_cash(conn)), Decimal("0")) == Decimal("999.00")
+    historical_cash = latest_official_postfinance_cash(conn, as_of="2026-07-27")
+    assert sum((item.amount_chf for item in historical_cash), Decimal("0")) == original_cash_total
+    cutoff_cash = latest_official_postfinance_cash(
+        conn,
+        as_of="2026-08-01",
+        data_cutoff="2026-07-31T23:59:59Z",
+    )
+    assert sum((item.amount_chf for item in cutoff_cash), Decimal("0")) == original_cash_total
+    historical_positions = latest_official_postfinance_positions(conn, as_of="2026-07-27")
+    assert sum(
+        (item.market_value_chf for item in historical_positions.values()), Decimal("0")
+    ) == original_position_total
+    assert {item.valuation_at for item in historical_positions.values()} == {"2026-07-27T09:14:30+02:00"}
+    current_positions = latest_official_postfinance_positions(conn)
+    assert sum((item.market_value_chf for item in current_positions.values()), Decimal("0")) == Decimal("300.00")
+    assert {item.valuation_at for item in current_positions.values()} == {"2026-08-01T09:14:30+02:00"}
+
+    selected = next(iter(current_positions.values()))
+    baseline_quantity = selected.quantity
+    conn.execute(
+        """INSERT INTO transactions(
+               transaction_id,transaction_type,account_id,instrument_id,trade_date,
+               settlement_date,quantity,currency_original,source_type,source_id,
+               external_transaction_id,row_hash,is_confirmed,quality_status,created_at,
+               updated_at,is_voided)
+           VALUES('post-snapshot-buy','buy',?,?, '2026-08-02','2026-08-02','1',
+                  'CHF','manual_confirmed_trade','post-snapshot-buy','post-snapshot-buy',?,
+                  1,'ok','2026-08-02T10:00:00Z','2026-08-02T10:00:00Z',0)""",
+        (CASH, selected.instrument_id, sha256(b"post-snapshot-buy").hexdigest()),
+    )
+    conn.commit()
+    dashboard_position = next(
+        row
+        for row in get_positions(conn)
+        if row["account_id"] == DEPOT and row["instrument_id"] == selected.instrument_id
+    )
+    assert Decimal(dashboard_position["quantity"]) == baseline_quantity + Decimal("1")
+    canonical_position = next(
+        item
+        for item in confirmed_canonical_positions(conn, as_of="2026-08-02")
+        if item.account_id == DEPOT and item.instrument_id == selected.instrument_id
+    )
+    assert canonical_position.quantity == baseline_quantity + Decimal("1")
"""Central role-aware portfolio aggregation for verified PostFinance projections.

Official account totals are reconciliation controls, never additive holdings. Source FX
rates and line values can be rounded independently, so component CHF values are allocated
deterministically to the signed-off source subtotal without inventing an extra position.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from sqlite3 import Connection

MONEY = Decimal("0.01")

@dataclass(frozen=True)
class OfficialCashComponent:
    account_id: str
    currency: str
    amount_original: Decimal
    source_fx_rate_to_chf: Decimal
    amount_chf: Decimal
    snapshot_date: str
    valuation_at: str
    reconciliation_method: str = "official_total_pro_rata_rounded_fx_v1"

@dataclass(frozen=True)
class OfficialPositionOverride:
    account_id: str
    instrument_id: str
    asset_class: str
    quantity: Decimal
    market_price_original: Decimal
    price_currency: str
    market_value_chf: Decimal
    snapshot_date: str
    valuation_at: str
    reconciliation_method: str = "official_asset_class_residual_v1"

@dataclass(frozen=True)
class AuditedMarketValue:
    account_id: str
    instrument_id: str
    value_chf: Decimal
    close: Decimal | None
    currency: str
    as_of: str
    completed_at: str
    provider: str
    quantity: Decimal | None = None

def _decimal(value: object) -> Decimal:
    return Decimal(str(value))

def _money(value: Decimal) -> Decimal:
    return value.quantize(MONEY, rounding=ROUND_HALF_UP)

def postfinance_account_roles(conn: Connection) -> dict[str, str]:
    """Return canonical PostFinance role mappings without name-based inference."""

    return {
        str(row["role"]): str(row["account_id"])
        for row in conn.execute(
            "SELECT role,account_id FROM postfinance_account_roles ORDER BY role"
        ).fetchall()
    }

def _allocate_pro_rata(values: list[Decimal], target: Decimal) -> list[Decimal]:
    """Reconcile rounded source components to an authoritative signed-off subtotal."""

    if not values:
        return []
    raw_total = sum(values, Decimal("0"))
    if raw_total <= 0 or target < 0:
        raise ValueError("Official portfolio components cannot be reconciled")
    allocated = [_money(value * target / raw_total) for value in values]
    residual = _money(target - sum(allocated, Decimal("0")))
    if residual:
        largest = max(range(len(values)), key=values.__getitem__)
        allocated[largest] = _money(allocated[largest] + residual)
    if sum(allocated, Decimal("0")) != _money(target):
        raise ValueError("Official portfolio component allocation does not reconcile")
    return allocated

def latest_official_postfinance_cash(
    conn: Connection,
    *,
    as_of: str | None = None,
    data_cutoff: str | None = None,
) -> list[OfficialCashComponent]:
    roles = postfinance_account_roles(conn)
    account_id = roles.get("etrading_cash")
    if not account_id:
        return []
    conditions: list[str] = []
    params: list[str] = []
    if as_of:
        conditions.append("date(snapshot_at)<=date(?)")
        params.append(as_of)
    if data_cutoff:
        conditions.append("datetime(created_at)<=datetime(?)")
        params.append(data_cutoff)
    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
    snapshot = conn.execute(
        f"""SELECT snapshot_id,snapshot_at,cash_chf
            FROM postfinance_snapshots {where}
            ORDER BY snapshot_at DESC,datetime(created_at) DESC LIMIT 1""",
        params,
    ).fetchone()
    if not snapshot:
        return []
    rows = conn.execute(
        """SELECT currency,amount_original,fx_rate_to_chf
           FROM postfinance_snapshot_cash WHERE snapshot_id=? ORDER BY currency""",
        (snapshot["snapshot_id"],),
    ).fetchall()
    raw_values = [
        _decimal(row["amount_original"]) * _decimal(row["fx_rate_to_chf"])
        for row in rows
    ]
    target = _money(_decimal(snapshot["cash_chf"]))
    allocated = _allocate_pro_rata(raw_values, target)
    valuation_at = str(snapshot["snapshot_at"])
    return [
        OfficialCashComponent(
            account_id=account_id,
            currency=str(row["currency"]),
            amount_original=_decimal(row["amount_original"]),
            source_fx_rate_to_chf=_decimal(row["fx_rate_to_chf"]),
            amount_chf=value,
            snapshot_date=valuation_at[:10],
            valuation_at=valuation_at,
        )
        for row, value in zip(rows, allocated, strict=True)
    ]

def latest_official_postfinance_positions(
    conn: Connection,
    *,
    as_of: str | None = None,
    data_cutoff: str | None = None,
) -> dict[tuple[str, str], OfficialPositionOverride]:
    roles = postfinance_account_roles(conn)
    account_id = roles.get("etrading_depot")
    if not account_id:
        return {}
    conditions: list[str] = []
    params: list[str] = []
    if as_of:
        conditions.append("date(snapshot_at)<=date(?)")
        params.append(as_of)
    if data_cutoff:
        conditions.append("datetime(created_at)<=datetime(?)")
        params.append(data_cutoff)
    where = f"WHERE {' AND '.join(conditions)}" if conditions else ""
    snapshot = conn.execute(
        f"""SELECT snapshot_id,snapshot_at,stocks_chf,etfs_chf
            FROM postfinance_snapshots {where}
            ORDER BY snapshot_at DESC,datetime(created_at) DESC LIMIT 1""",
        params,
    ).fetchone()
    if not snapshot:
        return {}
    rows = conn.execute(
        """SELECT instrument_id,asset_class,quantity,price_original,price_currency,value_chf
           FROM postfinance_snapshot_positions
           WHERE snapshot_id=? ORDER BY asset_class,instrument_id""",
        (snapshot["snapshot_id"],),
    ).fetchall()
    targets = {
        "stock": _money(_decimal(snapshot["stocks_chf"])),
        "etf": _money(_decimal(snapshot["etfs_chf"])),
    }
    allocated_by_instrument: dict[str, Decimal] = {}
    for asset_class, target in targets.items():
        members = [row for row in rows if str(row["asset_class"]).lower() == asset_class]
        allocated = _allocate_pro_rata(
            [_decimal(row["value_chf"]) for row in members], target
        )
        allocated_by_instrument.update(
            {
                str(row["instrument_id"]): value
                for row, value in zip(members, allocated, strict=True)
            }
        )
    valuation_at = str(snapshot["snapshot_at"])
    return {
        (account_id, str(row["instrument_id"])): OfficialPositionOverride(
            account_id=account_id,
            instrument_id=str(row["instrument_id"]),
            asset_class=str(row["asset_class"]).lower(),
            quantity=_decimal(row["quantity"]),
            market_price_original=_decimal(row["price_original"]),
            price_currency=str(row["price_currency"]),
            market_value_chf=allocated_by_instrument[str(row["instrument_id"])],
            snapshot_date=valuation_at[:10],
            valuation_at=valuation_at,
        )
        for row in rows
    }

def post_snapshot_postfinance_quantity_deltas(
    conn: Connection,
    positions: dict[tuple[str, str], OfficialPositionOverride],
    *,
    as_of: str | None = None,
) -> dict[str, Decimal]:
    """Replay confirmed buys/sells after the official snapshot baseline."""

    if not positions:
        return {}
    roles = postfinance_account_roles(conn)
    account_ids = [
        account_id
        for role in ("etrading_depot", "etrading_cash")
        if (account_id := roles.get(role))
    ]
    if not account_ids:
        return {}
    snapshot_date = min(item.snapshot_date for item in positions.values())
    effective_as_of = as_of or "9999-12-31"
    placeholders = ",".join("?" for _ in account_ids)
    rows = conn.execute(
        f"""SELECT instrument_id,lower(transaction_type) transaction_type,quantity
            FROM transactions
            WHERE account_id IN ({placeholders})
              AND instrument_id IS NOT NULL
              AND COALESCE(is_confirmed,0)=1
              AND COALESCE(is_voided,0)=0


sed -n '240,520p' src/jarvis_finance/services/portfolio_aggregation.py && git diff -- src/jarvis_finance/services/equity_service.py src/jarvis_finance/services/portfolio_analytics.py src/jarvis_finance/services/portfolio_data.py src/jarvis_finance/services/postfinance_service.py