Files
sure/app/models/assistant/function/list_account_statements.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

148 lines
5.5 KiB
Ruby

# frozen_string_literal: true
class Assistant::Function::ListAccountStatements < Assistant::Function
include Assistant::Function::StatementVaultSupport
DEFAULT_LIMIT = 25
MAX_LIMIT = 100
class << self
def name
"list_account_statements"
end
def description
<<~INSTRUCTIONS
List documents in the family's Statement Vault with their identity and
provenance: SHA-256, filename, statement period, linked account, and
review status.
Use this to answer "which statements do we hold?", to find the document
backing a figure, to check whether a file is already archived (filter by
`content_sha256`), or to work the review queue (filter by
`review_status: "unmatched"` for documents awaiting a human's account
decision).
This returns document identity, not document contents. To search inside
uploaded documents use `search_family_files`; to fetch one statement's
reconciliation figures and a download link use `get_account_statement`.
There is no cursor or offset. `has_more: true` means the result was
truncated raise `limit` (up to #{MAX_LIMIT}) or narrow the filters to see
the rest; paging forward is not possible.
Example:
```
list_account_statements({
review_status: "unmatched",
overlapping_from: "2026-01-01"
})
```
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
properties: {
account_id: {
type: "string",
description: "Only statements linked to this account UUID."
},
review_status: {
type: "string",
enum: AccountStatement.review_statuses.keys,
description: "unmatched = awaiting a human account decision, linked = attached to an account, rejected = the suggested match was declined."
},
content_sha256: {
type: "string",
description: "Look up a specific document by the SHA-256 of its contents (hex; case-insensitive). Use this to check whether a file is already archived."
},
overlapping_from: {
type: "string",
description: "ISO 8601 date (YYYY-MM-DD). Only statements whose period overlaps this date or later, i.e. whose period ENDS on or after it."
},
overlapping_until: {
type: "string",
description: "ISO 8601 date (YYYY-MM-DD). Only statements whose period overlaps this date or earlier, i.e. whose period STARTS on or before it."
},
limit: {
type: "integer",
description: "Maximum statements to return (default #{DEFAULT_LIMIT}, max #{MAX_LIMIT})."
}
}
)
end
def call(params = {})
return not_a_statement_manager unless statement_manager?
# Visibility is filtered in SQL, not after the fact. Post-filtering a page
# would both underfill it and — because there is no cursor — make a statement
# permanently unreachable whenever enough newer rows the caller cannot see sit
# in front of it. Mirrors AccountStatement#viewable_by? for a statement
# manager: unlinked statements are visible, linked ones follow the account.
scope = family.account_statements
.where(account_id: nil)
.or(family.account_statements.where(account_id: user.accessible_accounts.select(:id)))
.includes(:account, :suggested_account)
.ordered
if params["account_id"].present?
return error("invalid_account_id", "account_id must be a UUID.") unless valid_uuid?(params["account_id"])
scope = scope.where(account_id: params["account_id"])
end
if params["review_status"].present?
status = params["review_status"].to_s
unless AccountStatement.review_statuses.key?(status)
return error("invalid_review_status", "review_status must be one of: #{AccountStatement.review_statuses.keys.join(", ")}.")
end
scope = scope.where(review_status: status)
end
# Downcased because the column is constrained to lowercase hex
# (chk_account_statements_content_sha256), so uppercase input would not merely
# be unlikely to match — it could never match, and the agent would read the
# empty result as "not archived" and upload a duplicate.
if params["content_sha256"].present?
scope = scope.where(content_sha256: params["content_sha256"].to_s.strip.downcase)
end
if params["overlapping_from"].present?
date = parse_date(params["overlapping_from"])
return error("invalid_date", "overlapping_from must be an ISO 8601 date (YYYY-MM-DD).") unless date
scope = scope.where("period_end_on >= ?", date)
end
if params["overlapping_until"].present?
date = parse_date(params["overlapping_until"])
return error("invalid_date", "overlapping_until must be an ISO 8601 date (YYYY-MM-DD).") unless date
scope = scope.where("period_start_on <= ?", date)
end
limit = (params["limit"] || DEFAULT_LIMIT).to_i.clamp(1, MAX_LIMIT)
# Over-fetch by one to report has_more without a second count query. The rows
# are already visibility-scoped, so the page is never underfilled and the
# count discloses nothing the caller cannot see.
rows = scope.limit(limit + 1).to_a
statements = rows.first(limit)
{
success: true,
returned: statements.size,
has_more: rows.size > limit,
statements: statements.map { |statement| statement_payload(statement) }
}
end
end