Files
sure/app/models/assistant/function/get_account_statement.rb
Claude 89a2f53126 Stop the vault tools promising verification they don't perform
Three findings from the automated review passes, all confirmed against the
code before changing anything.

The download URL was dead on arrival for the caller it was built for. Sure
serves stored files through Active Storage controllers that
config/initializers/active_storage_authorization.rb gates on
`viewable_by?(Current.user)` — a signed-in browser session. An MCP client has
a bearer token and no session, so following the URL would have redirected to
sign-in. Removed it rather than leaving a link that cannot work, and the
description now points at search_family_files or the vault UI.

Coverage called a month `covered` when a document merely existed. An
unreconciled statement is not mismatched, so it took the `covered` branch, and
the payload carried nothing to correct the reading — the same "advertised
verification that never happened" bug fixed last round in
get_account_statement, in a second place. Months now carry their own
reconciliation_status, and the description says covered means presence, not
agreement.

Listing filtered visibility after limiting. Beyond underfilling a page, with
no cursor and a 100-row cap an accessible statement behind enough newer
invisible ones was unreachable. Visibility now lives in the query, mirroring
viewable_by? for a statement manager.

Also: rescue unexpected upload failures into a tool error instead of a raw
exception string, derive the documented size limit from MAX_FILE_SIZE, list
every coverage status in mcp.md, and cover the failed-reconciliation and
base64-normalisation branches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn
2026-08-01 20:10:34 +00:00

115 lines
4.0 KiB
Ruby

# frozen_string_literal: true
class Assistant::Function::GetAccountStatement < Assistant::Function
include Assistant::Function::StatementVaultSupport
class << self
def name
"get_account_statement"
end
def description
<<~INSTRUCTIONS
Fetch one Statement Vault document by ID: its identity (SHA-256, filename,
period), any balances recorded for it, and reconciliation checks against
the ledger.
IMPORTANT reconciliation is usually empty, and its absence means nothing.
The checks only exist once a human has typed the statement's opening and
closing balances into the Statement Vault UI. Nothing extracts them from
the document, so a statement you uploaded through `upload_account_statement`
comes back with `reconciliation_checks: []` and
`reconciliation_status: "unavailable"`. That is "nobody has entered the
figures", NOT "the document agrees with the ledger". Never report an
unreconciled statement as verified.
When the balances have been entered, each check compares that figure
against the account's recorded balance:
- `opening_balance` / `closing_balance` the statement's figure vs the ledger's
- `period_movement` the change across the period, both sides
Each reports `matched` or `mismatched` (tolerance: 0.01). A mismatch means
the ledger and the document disagree report it, and do not paper over it
by adjusting the number to fit. Note this is ledger agreement only: nothing
here verifies that the document's own line items sum to its printed total.
That parse-integrity check belongs to whatever extracted the figures.
This does NOT return the document's bytes and cannot give you a link that
works for you: Sure serves stored files only to a signed-in browser
session, which an MCP client does not have. To read a document, either
search its contents with `search_family_files`, or point the user at
Settings -> Statement Vault to open it themselves.
Example:
```
get_account_statement({ statement_id: "abc123-def456" })
```
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
required: [ "statement_id" ],
properties: {
statement_id: {
type: "string",
description: "UUID of the statement, as returned by list_account_statements or upload_account_statement."
}
}
)
end
def call(params = {})
return not_a_statement_manager unless statement_manager?
statement = find_statement(params["statement_id"])
unless statement&.viewable_by?(user)
return error("not_found", "No statement found with that ID that this user can view.")
end
checks = reconciliation_payload(statement)
status = statement.reconciliation_status
{
success: true,
statement: statement_payload(statement).merge(
reconciliation_status: status,
reconciliation_checks: checks,
# Spelled out in the payload, not just the tool description: an agent
# reading only the JSON must not read an empty check list as agreement.
reconciliation_note: unavailable_note(status)
).compact
}
end
private
def unavailable_note(status)
return nil unless status == "unavailable"
"No reconciliation has been performed: this statement has no opening/closing " \
"balances recorded, and nothing extracts them from the document. Someone must " \
"enter them in the Statement Vault UI. This is not evidence that the statement " \
"agrees with the ledger."
end
def reconciliation_payload(statement)
statement.reconciliation_checks.map do |check|
{
check: check[:key],
statement_amount: check[:statement_amount].to_s,
ledger_amount: check[:ledger_amount].to_s,
difference: check[:difference].to_s,
status: check[:status]
}
end
end
end