Files
sure/app/models/assistant/function/upload_account_statement.rb
Claude ec38a89d0c Expose the Statement Vault to external agents over MCP
A user wants to manage patrimonial history — a document-backed record of a
family's wealth where every figure traces back to the statement it came from —
by pointing an external agent harness at Sure. That model belongs in the
harness, not in Sure: it needs numbered build deltas, golden tests and closed
periods that a mutable Postgres row cannot provide.

What Sure was missing was the seam. The Statement Vault already does most of
the work — original bytes retained, SHA-256 dedup, period detection, account
matching with a confidence score, reconciliation against ledger balances, and a
month-by-month coverage map — but it is reachable only from the web UI. An
agent could not archive a document, cite one, or check for gaps.

Adds five preview MCP tools over what already exists, plus a citation grammar
for values the agent writes:

- upload_account_statement, list_account_statements, get_account_statement,
  get_statement_coverage
- record_valuation, whose source citation is parsed rather than trusted:
  ["estimated: "] citation [" (grade: A|B|C)"]. An uncited or free-styled
  value is rejected at the write boundary instead of landing in the ledger
  looking authoritative.

link and reject are deliberately not exposed. Attaching a statement to an
account is the human's decision, and the vault UI is where it is made; the
agent reports the suggested match and stops there.

Assistant.function_classes now takes a user so preview tools stay out of the
default surface. They are hidden from tools/list and not callable by name
without the preference enabled, and the vault tools re-check the manager role
and per-account permissions, since MCP calls never pass through a controller.

Docs: the blueprint this implements, and a guide covering which side owns which
layer, the vocabulary map between the two, the monthly runbook, and the gaps
(non-user holders, non-statement documents, one value per date).

No migrations, no API endpoints, no UI.

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

184 lines
6.4 KiB
Ruby

# frozen_string_literal: true
class Assistant::Function::UploadAccountStatement < Assistant::Function
include Assistant::Function::StatementVaultSupport
class << self
def name
"upload_account_statement"
end
def description
<<~INSTRUCTIONS
Store a statement document (PDF, CSV or XLSX) in the family's Statement Vault,
the canonical archive of primary-source financial documents.
The vault keeps the original bytes and indexes them by SHA-256, so uploading
the same file twice is safe: the existing statement is returned with
`duplicate: true` and nothing new is created.
On upload the vault reads the document's period, institution and account
hints, and proposes a matching account with a confidence score. It does NOT
link the statement to that account linking is a human decision made in
Settings -> Statement Vault. Report the suggestion; don't claim the link.
Provide the file as base64 in `content_base64`. Maximum size is 25 MB.
Example:
```
upload_account_statement({
filename: "private-bank_2026-03-31_monthly-statement.pdf",
content_base64: "JVBERi0xLjcK..."
})
```
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
required: %w[filename content_base64],
properties: {
filename: {
type: "string",
description: "Filename including extension. Must be one of: #{AccountStatement::ACCEPTED_FILE_EXTENSIONS.join(", ")}."
},
content_base64: {
type: "string",
description: "The document's raw bytes, base64-encoded."
},
account_id: {
type: "string",
description: "Optional account UUID to link the statement to on upload. Only pass this when the user has told you which account the document belongs to otherwise omit it and let the vault suggest a match for the user to confirm."
}
}
)
end
def call(params = {})
return not_a_statement_manager unless statement_manager?
filename = params["filename"].to_s.strip
return error("filename_required", "Please provide a filename with an extension.") if filename.blank?
unless AccountStatement::ACCEPTED_FILE_EXTENSIONS.include?(File.extname(filename).downcase)
return error(
"unsupported_file_type",
"The Statement Vault accepts #{AccountStatement::ACCEPTED_FILE_EXTENSIONS.join(", ")} files only.",
accepted_extensions: AccountStatement::ACCEPTED_FILE_EXTENSIONS
)
end
content = decode_content(params["content_base64"])
return error("invalid_content", "content_base64 could not be decoded as base64.") if content.nil?
return error("empty_file", "The decoded file is empty.") if content.empty?
if content.bytesize > AccountStatement::MAX_FILE_SIZE
return error(
"file_too_large",
"The file is #{content.bytesize} bytes; the maximum is #{AccountStatement::MAX_FILE_SIZE} bytes."
)
end
account = nil
if params["account_id"].present?
account = family.accounts.writable_by(user).find_by(id: params["account_id"]) if valid_uuid?(params["account_id"])
unless account
return error(
"account_not_found",
"No writable account matched that account_id. Omit account_id to upload the statement unlinked and let the vault suggest a match."
)
end
end
prepared = AccountStatement.prepare_upload!(upload_for(content, filename))
statement = AccountStatement.create_from_prepared_upload!(family: family, account: account, prepared_upload: prepared)
{
success: true,
duplicate: false,
statement: statement_payload(statement),
message: "Stored #{statement.filename} in the Statement Vault."
}
rescue AccountStatement::DuplicateUploadError => e
duplicate_response(e.statement)
rescue AccountStatement::InvalidUploadError
error(
"invalid_file",
"The file failed validation: its contents don't match its extension, or it isn't a readable #{AccountStatement::ACCEPTED_FILE_EXTENSIONS.join("/")} document."
)
rescue ActiveRecord::RecordInvalid => e
error("validation_failed", e.record.errors.full_messages.join("; "))
end
private
# The existing copy may be filed against an account this user cannot see, so
# the dedup result is reported without the details that would disclose it.
def duplicate_response(statement)
if statement.viewable_by?(user)
{
success: true,
duplicate: true,
statement: statement_payload(statement),
message: "This document is already in the vault (same SHA-256). Returning the existing statement; nothing was created."
}
else
{
success: true,
duplicate: true,
statement: { content_sha256: statement.content_sha256 },
message: "This document is already in the vault, filed against an account this user cannot see. Nothing was created."
}
end
end
# Whitespace is stripped first because agents routinely wrap long base64
# across lines, but decoding stays strict after that: Base64.decode64 quietly
# discards characters it doesn't understand, which would archive corrupted
# bytes under a hash that looks perfectly legitimate.
def decode_content(value)
return nil if value.blank?
Base64.strict_decode64(value.to_s.gsub(/\s+/, ""))
rescue ArgumentError
nil
end
# AccountStatement.prepare_upload! expects an uploaded-file-like object so it
# can stream, size-check and sniff the content type. Reusing it (rather than
# building a PreparedUpload by hand) keeps the MCP path under exactly the same
# validations as the web upload form. Content type is left nil on purpose so
# the vault sniffs it from the bytes rather than trusting the caller.
def upload_for(content, filename)
DecodedUpload.new(StringIO.new(content), filename)
end
class DecodedUpload
attr_reader :original_filename, :content_type
def initialize(io, filename, content_type = nil)
@io = io
@original_filename = filename
@content_type = content_type
end
def read(*args)
@io.read(*args)
end
def rewind
@io.rewind
end
def size
@io.size
end
end
end