fix(mcp): query_journal defaults to status 'all' so totals equal ledger balances (#1863)
* fix(mcp): query_journal defaults to status 'all' so totals equal ledger balances Posted-only was the default, but storno bookkeeping keeps both the reversed original and its posted storno on the account: one-leg sums are never balances. A customer's agent summed posted-only lines over a storno-heavy quarter, found phantom VAT residuals on 2614/2641/2645/2647 and asked support to revert correct books. - default status 'all' (posted + reversed), the same inclusion rule as trial balance, GL and SIE export - explicit 'posted'/'reversed' get status_filter_warning when the opposite leg exists in range (entry-level head count, advisory) - unknown status values now throw instead of matching nothing - sibling description trims fund the additions within the tools/list payload budget Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): make status_filter_warning claim strictly true, hint status:'posted' for tag preview Review feedback (PR Agent + skeptics): the entry-level opposite-status count cannot prove the line-filtered totals are wrong, so the warning now says one-leg totals CAN differ from balances; verb agrees with a count of one. tag_journal_lines preview hint tells agents to pass status:'posted' now that query_journal defaults to 'all'. Description trims keep the tools/list payload inside the budget guard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(mcp): state the status_filter_warning count scope in the message CodeRabbit: the opposite-status count applies entry-level filters only, so say so in the warning instead of letting an account-scoped caller read the count as account-scoped. Line-level scoping declined as documented in DECISIONS.md (it would re-run the full line fetch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
174f315d9c
commit
0a3cb1a31a
@@ -1188,5 +1188,6 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-24] Declared currency/voucher_series nullable in three MCP listing schemas on column-nullability alone (no traced null producer): loosening an output schema can only stop false validation failures, never cause one, and legacy rows predate the columns' defaults. Declined (for now) a full Ajv execute-vs-schema round-trip harness in output-schema.test.ts: right long-term answer to this bug class, but a session-sized project of its own; the audit's seven confirmed sites are pinned by a targeted declaration test instead.
|
||||
[2026-08-24] Manual matching (PR 6b) ships N:1 only (many outside rows -> one verifikat): bank links are independent per transaction (the engine allows it by design), skattekonto groups are all-or-nothing with the sum settling the verifikat (one guarded UPDATE, partial hit rolled back). 1:M (one row over several verifikat) and residual booking wait for a link table in 6c: the single journal_entry_id pointer on both row kinds cannot express them, and faking it (pointing the row at the residual verifikat) would break the bridge. The worksheet therefore enables Koppla only when the selection nets to zero and says so otherwise.
|
||||
[2026-08-24] Skattekonto payment file gets pain.001 through the supplier-payment generator (generateSupplierPain001), not the salary pain001 generator: the payment is a plain BG+OCR giro transfer (no SALA CtgyPurp), and the supplier dialect is the Validex-validated shape for exactly that; the LB path stays the default so nothing changes for banks still on LB.
|
||||
[2026-08-24] query_journal status default 'posted' -> 'all' (posted+reversed, the trial-balance inclusion rule) after a customer's agent summed posted-only lines over a storno-heavy Q2, found phantom VAT residuals on 2614/2641/2645/2647 and demanded a revert of correct books: one-leg sums are never balances. Funded the new status_filter_warning field and richer status docs inside the tools/list token budget by trimming sibling descriptions in the same tool rather than bumping the 59.95K ceiling (the payload guard's own guidance); warning fires off an entry-level opposite-status head count, exact for what the sentence claims and cheap, instead of re-running the line fetch.
|
||||
[2026-08-24] Detach-duplicate underlag ships as a SECURITY DEFINER RPC (detach_underlag_duplicate) instead of loosening the document triggers: the WORM guards stay intact for every other path, the carve-out is transaction-local (gnubok.allow_delete) and audit-logged first, and detach is refused unless another anchored underlag remains on the verifikat (BFL 5 kap 7 par) AND a remaining sibling has an identical sha256_hash (only byte-identical duplicates detach; skeptic-hardened 2026-08-24, along with an enforced posted-status guard and company_id on the audit row). Pinned docs (transactions.document_id / supplier_invoices.document_id) stay replace-only.
|
||||
[2026-08-24] Single-call chat console (general.help, AskConsole → /api/agent/ask) now carries the thread's earlier turns into every model call, via a new optional `history` on the provider-agnostic GenerateTextRequest (real message turns before the prompt in BOTH adapters: Anthropic-family messages array, OpenAI-compatible via AI SDK `messages`; an absent/empty history leaves the request byte-identical to the single-turn call, so hosted extraction and every other caller are untouched). The 08-20 RIP-3 cutover made each turn stateless (conversationId was only the tool actor id), so a follow-up in a resumed thread was answered blind (user report: "frågar vad jag refererar till"). History is loaded server-side from agent_messages (loadChatHistory: text only, hidden + tool rows dropped, alternation repaired, newest 16 rows / 10k chars) rather than sent by the client, so the client cannot forge earlier turns and old streaming threads replay cleanly. Rejected: inlining a transcript into the prompt (works everywhere but weaker turn semantics and blurs data vs instructions) and loading history in AskConsole (client-trusted history). Separately: the docked assistant panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and reopens it after a full reload (the deploy prompt's "Ladda om" wiped it); sessionStorage, not user_preferences, because this is this-tab-this-session state that must not follow the user to other devices or tabs. And DeployReloadPrompt's full-width wrapper gets pointer-events-none: at z-[60] after the panel in DOM order it swallowed clicks on the panel's composer ("går ej att skriva").
|
||||
|
||||
@@ -249,6 +249,7 @@ function makeLineRow(opts: {
|
||||
entry_notes?: string | null
|
||||
voucher_number?: number
|
||||
entry_date?: string
|
||||
entry_status?: string
|
||||
}) {
|
||||
return {
|
||||
id: opts.id,
|
||||
@@ -268,7 +269,7 @@ function makeLineRow(opts: {
|
||||
description: opts.entry_description ?? '',
|
||||
notes: opts.entry_notes ?? null,
|
||||
source_type: 'bank_transaction',
|
||||
status: 'posted',
|
||||
status: opts.entry_status ?? 'posted',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1006,3 +1007,102 @@ describe('gnubok_query_journal: accounts argument normalization', () => {
|
||||
).rejects.toThrow(/capped at 50/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gnubok_query_journal: status default and balance integrity', () => {
|
||||
const tool = () => tools.find((t) => t.name === 'gnubok_query_journal')!
|
||||
|
||||
// A storno pair on 2614: the reversed original (credit) and its posted
|
||||
// storno (debit). Under the ledger inclusion rule they net to zero; a
|
||||
// posted-only view sees just the storno leg and fabricates a +940.27
|
||||
// "balance". This is the exact shape that made a customer's agent find
|
||||
// phantom VAT residuals and demand a revert of correct books.
|
||||
const stornoPair = () => [
|
||||
makeLineRow({
|
||||
id: 'l-orig', account_number: '2614', debit_amount: 0, credit_amount: 940.27,
|
||||
entry_status: 'reversed', entry_date: '2026-05-31',
|
||||
}),
|
||||
makeLineRow({
|
||||
id: 'l-storno', account_number: '2614', debit_amount: 940.27, credit_amount: 0,
|
||||
entry_status: 'posted', entry_date: '2026-05-31', voucher_number: 2,
|
||||
}),
|
||||
]
|
||||
|
||||
it("declares status_filter_warning in the output schema and 'all' as the documented default", () => {
|
||||
const t = tool()
|
||||
const out = t.outputSchema as { properties?: Record<string, unknown> }
|
||||
expect(out.properties?.status_filter_warning).toBeDefined()
|
||||
const statusProp = (t.inputSchema as {
|
||||
properties: Record<string, { description?: string }>
|
||||
}).properties.status
|
||||
expect(statusProp.description).toMatch(/Default 'all'/)
|
||||
})
|
||||
|
||||
it('defaults to posted + reversed so a storno pair nets to zero (ledger rule)', async () => {
|
||||
const { supabase, entryQueries } = makeTwoStepTextMock(stornoPair())
|
||||
const result = (await tool().execute(
|
||||
{ accounts: ['2614'] },
|
||||
'company-1', 'user-1', supabase,
|
||||
)) as {
|
||||
lines: unknown[]
|
||||
totals: { net: number }
|
||||
status_filter_warning?: string
|
||||
applied_filters: { status: string }
|
||||
}
|
||||
|
||||
expect(result.applied_filters.status).toBe('all')
|
||||
expect(result.lines).toHaveLength(2)
|
||||
expect(result.totals.net).toBe(0)
|
||||
expect(result.status_filter_warning).toBeUndefined()
|
||||
// The entry pass filters on BOTH statuses; no separate opposite-status
|
||||
// count query runs on the default path.
|
||||
const statusFilters = entryQueries().flatMap((q) =>
|
||||
q.filters.filter((f) => f.column === 'status'),
|
||||
)
|
||||
expect(statusFilters).toEqual([
|
||||
{ op: 'in', column: 'status', value: ['posted', 'reversed'] },
|
||||
])
|
||||
})
|
||||
|
||||
it("explicit status 'posted' returns one leg and warns that totals are not balances", async () => {
|
||||
const { supabase, entryQueries } = makeTwoStepTextMock(stornoPair())
|
||||
const result = (await tool().execute(
|
||||
{ accounts: ['2614'], status: 'posted' },
|
||||
'company-1', 'user-1', supabase,
|
||||
)) as {
|
||||
lines: Array<{ line_id: string }>
|
||||
totals: { net: number }
|
||||
status_filter_warning?: string
|
||||
}
|
||||
|
||||
expect(result.lines.map((l) => l.line_id)).toEqual(['l-storno'])
|
||||
expect(result.totals.net).toBe(940.27)
|
||||
expect(result.status_filter_warning).toMatch(/can differ from account balances/)
|
||||
expect(result.status_filter_warning).toMatch(/status 'all'/)
|
||||
// Singular/plural agreement: one excluded entry reads "1 entry ... is".
|
||||
expect(result.status_filter_warning).toMatch(/1 entry with status 'reversed' in the filtered range is excluded/)
|
||||
// The warning is grounded in a real opposite-status count query.
|
||||
const oppositeCount = entryQueries().some((q) =>
|
||||
q.filters.some((f) => f.op === 'eq' && f.column === 'status' && f.value === 'reversed'),
|
||||
)
|
||||
expect(oppositeCount).toBe(true)
|
||||
})
|
||||
|
||||
it('omits the warning when the one-leg filter excluded nothing', async () => {
|
||||
const rows = [
|
||||
makeLineRow({ id: 'l1', account_number: '2614', debit_amount: 100, entry_status: 'posted' }),
|
||||
]
|
||||
const { supabase } = makeTwoStepTextMock(rows)
|
||||
const result = (await tool().execute(
|
||||
{ accounts: ['2614'], status: 'posted' },
|
||||
'company-1', 'user-1', supabase,
|
||||
)) as { status_filter_warning?: string }
|
||||
expect(result.status_filter_warning).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects an unknown status value instead of matching nothing', async () => {
|
||||
const { supabase } = makeTwoStepTextMock([])
|
||||
await expect(
|
||||
tool().execute({ status: 'draft' }, 'company-1', 'user-1', supabase),
|
||||
).rejects.toThrow(/status must be/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7380,7 +7380,7 @@ export const tools: McpTool[] = [
|
||||
filters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
description: 'Line selection: at least one filter required. Preview the match set with gnubok_query_journal (same filter fields) first.',
|
||||
description: "Line selection: at least one filter required. Preview the match set with gnubok_query_journal (same filter fields plus status:'posted') first.",
|
||||
properties: {
|
||||
account_from: { type: 'string', description: 'Lowest account number (inclusive), e.g. "4010".' },
|
||||
account_to: { type: 'string', description: 'Highest account number (inclusive).' },
|
||||
@@ -7907,7 +7907,7 @@ export const tools: McpTool[] = [
|
||||
{
|
||||
name: 'gnubok_query_journal',
|
||||
title: 'Query Journal Lines',
|
||||
description: "Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher, source, status, dimensions bag, free-text. group_by/group_by_dimension aggregation; include_dimensions returns each line's bag. Lines + totals over the full match set (totals_scope).",
|
||||
description: 'Flexible journal-line query for ad-hoc questions. Filters: account, date, amount, voucher, source, status, dimensions, free-text. group_by/group_by_dimension aggregation; include_dimensions returns line bags. Lines + totals over the full match set.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
@@ -7924,7 +7924,7 @@ export const tools: McpTool[] = [
|
||||
voucher_number_from: { type: 'number', description: 'Lowest voucher number (inclusive)' },
|
||||
voucher_number_to: { type: 'number', description: 'Highest voucher number (inclusive)' },
|
||||
source_type: { type: 'string', description: 'Filter by source: bank_transaction, invoice_created, supplier_invoice, currency_revaluation, year_end, opening_balance, etc.' },
|
||||
status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: 'Default: posted' },
|
||||
status: { type: 'string', enum: ['posted', 'reversed', 'all'], description: "Default 'all' (posted + reversed: totals equal ledger balances). One-leg totals are NOT balances." },
|
||||
project: { type: 'string', description: 'Filter by project code (SIE dim 6)' },
|
||||
cost_center: { type: 'string', description: 'Filter by cost center (SIE dim 1)' },
|
||||
dimensions: {
|
||||
@@ -7938,7 +7938,7 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
group_by: { type: 'string', enum: ['account_number', 'voucher_series', 'source_type', 'cost_center', 'project'], description: 'Aggregate matching lines into groups by this field. Mutually exclusive with group_by_dimension.' },
|
||||
group_by_dimension: { type: 'string', description: 'Aggregate by SIE dimension number (e.g. "6" = projekt) from each line\'s dimensions bag; untagged → "(utan dimension)". Mutually exclusive with group_by.' },
|
||||
limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1-500 (default 100). Totals/groups always cover the FULL match set even when truncated (free-text search included).' },
|
||||
limit: { type: 'number', minimum: 1, maximum: 500, description: 'Max lines returned 1-500 (default 100); totals/groups still cover the full match set.' },
|
||||
},
|
||||
},
|
||||
outputSchema: {
|
||||
@@ -7947,10 +7947,10 @@ export const tools: McpTool[] = [
|
||||
properties: {
|
||||
lines: { type: 'array', items: { type: 'object' } },
|
||||
truncated: { type: 'boolean', description: 'True if more matching lines exist than were returned' },
|
||||
total_lines: { type: 'number', description: 'Total lines matching ALL filters (incl. amount). When amount_min/amount_max is set this reflects the filtered set, not the wider DB-side match.' },
|
||||
total_lines: { type: 'number', description: 'Total lines matching ALL filters, amount filter included.' },
|
||||
returned_lines: { type: 'number' },
|
||||
amount_filter_applied_post_fetch: { type: 'boolean', description: 'True if amount_min/amount_max was applied client-side after the DB fetch.' },
|
||||
db_matched_pre_amount_filter: { type: ['number', 'null'], description: 'Pre-amount-filter DB match count when amount_filter_applied_post_fetch is true; null otherwise.' },
|
||||
db_matched_pre_amount_filter: { type: ['number', 'null'], description: 'DB match count before the post-fetch amount filter; null when not applied.' },
|
||||
totals: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -7962,7 +7962,11 @@ export const tools: McpTool[] = [
|
||||
totals_scope: {
|
||||
type: 'string',
|
||||
enum: ['full_match'],
|
||||
description: 'Always full_match: totals/groups aggregate ALL matching lines regardless of limit, on free-text searches too. (returned_slice is no longer emitted; the field stays for older clients.)',
|
||||
description: 'Always full_match: totals/groups cover ALL matching lines regardless of limit (field kept for older clients).',
|
||||
},
|
||||
status_filter_warning: {
|
||||
type: 'string',
|
||||
description: 'Set when a one-leg status filter excluded opposite-status entries: totals may not equal account balances.',
|
||||
},
|
||||
groups: {
|
||||
type: 'array',
|
||||
@@ -7976,7 +7980,7 @@ export const tools: McpTool[] = [
|
||||
line_count: { type: 'number' },
|
||||
},
|
||||
},
|
||||
description: 'Present when group_by/group_by_dimension is set; sorted by |net| desc. Scope follows totals_scope.',
|
||||
description: 'Present when grouping is set; sorted by |net| desc, full-match scope.',
|
||||
},
|
||||
applied_filters: { type: 'object' },
|
||||
...DIMENSION_FILTER_OUTPUT_PROPS,
|
||||
@@ -7991,7 +7995,16 @@ export const tools: McpTool[] = [
|
||||
},
|
||||
async execute(args, companyId, userId, supabase) {
|
||||
const limit = Math.min(Math.max(1, Number(args.limit) || 100), 500)
|
||||
const status = (args.status as string) || 'posted'
|
||||
// Default 'all' (posted + reversed): the same inclusion rule every
|
||||
// report uses (trial balance, GL, SIE export). Storno bookkeeping keeps
|
||||
// both the reversed original and its posted storno on the account, so a
|
||||
// one-leg filter produces sums that are not balances; the old 'posted'
|
||||
// default made a real customer's agent find phantom VAT residuals in a
|
||||
// storno-heavy quarter and demand a revert of correct books.
|
||||
const status = (args.status as string) || 'all'
|
||||
if (status !== 'all' && status !== 'posted' && status !== 'reversed') {
|
||||
throw new Error("status must be 'posted', 'reversed' or 'all'")
|
||||
}
|
||||
// Hosts don't always enforce inputSchema: `accounts: 1630` (a bare
|
||||
// number) or `accounts: "1630"` both reached us as-is. A number has no
|
||||
// `.length`, so the filter was silently skipped while applied_filters
|
||||
@@ -8059,9 +8072,9 @@ export const tools: McpTool[] = [
|
||||
// journal_entry_lines. Every pass (plain and both text legs) applies
|
||||
// BOTH, so they always describe one match set; the text legs only add
|
||||
// their .ilike() on top.
|
||||
const filterEntries = (q: EntryLinesQuery): EntryLinesQuery => {
|
||||
const filterEntriesWithStatus = (q: EntryLinesQuery, st: string): EntryLinesQuery => {
|
||||
let e = q.eq('company_id', companyId)
|
||||
e = status === 'all' ? e.in('status', ['posted', 'reversed']) : e.eq('status', status)
|
||||
e = st === 'all' ? e.in('status', ['posted', 'reversed']) : e.eq('status', st)
|
||||
if (dateFrom) e = e.gte('entry_date', dateFrom)
|
||||
if (dateTo) e = e.lte('entry_date', dateTo)
|
||||
if (voucherSeries) e = e.eq('voucher_series', voucherSeries)
|
||||
@@ -8070,6 +8083,8 @@ export const tools: McpTool[] = [
|
||||
if (sourceType) e = e.eq('source_type', sourceType)
|
||||
return e
|
||||
}
|
||||
const filterEntries = (q: EntryLinesQuery): EntryLinesQuery =>
|
||||
filterEntriesWithStatus(q, status)
|
||||
|
||||
const filterLines = (q: EntryLinesQuery): EntryLinesQuery => {
|
||||
let l = q
|
||||
@@ -8334,6 +8349,34 @@ export const tools: McpTool[] = [
|
||||
.sort((a, b) => Math.abs(b.net) - Math.abs(a.net))
|
||||
}
|
||||
|
||||
// One-leg status filters get a balance-integrity warning when the
|
||||
// opposite leg exists in range. Entry-level head count only: cheap, and
|
||||
// exact for what the warning claims (excluded entries exist), without
|
||||
// re-running the line fetch. A failed count never fails the query; the
|
||||
// warning is advisory.
|
||||
let statusFilterWarning: string | undefined
|
||||
if (status !== 'all') {
|
||||
const opposite = status === 'posted' ? 'reversed' : 'posted'
|
||||
const { count, error: countError } = await filterEntriesWithStatus(
|
||||
supabase.from('journal_entries').select('id', { count: 'exact', head: true }),
|
||||
opposite
|
||||
)
|
||||
if (countError) {
|
||||
log.warn('query_journal opposite-status count failed', {
|
||||
companyId,
|
||||
userId,
|
||||
error: countError.message,
|
||||
})
|
||||
} else if ((count ?? 0) > 0) {
|
||||
statusFilterWarning =
|
||||
`${count} entr${count === 1 ? 'y' : 'ies'} with status '${opposite}' in the filtered range ` +
|
||||
`${count === 1 ? 'is' : 'are'} excluded by status='${status}' (count scoped by date/voucher/` +
|
||||
`source filters only, not by account or dimension filters). Storno bookkeeping keeps both ` +
|
||||
`the reversed original and its storno on the account, so one-leg totals can differ from ` +
|
||||
`account balances. Re-run with status 'all' (the default) for ledger-accurate sums.`
|
||||
}
|
||||
}
|
||||
|
||||
// The full match set anchors total_lines / truncated / pre-amount count
|
||||
// on every path; `lines` is the first `limit` of it in display order.
|
||||
return {
|
||||
@@ -8349,6 +8392,7 @@ export const tools: McpTool[] = [
|
||||
net: Math.round((totalDebit - totalCredit) * 100) / 100,
|
||||
},
|
||||
totals_scope: 'full_match',
|
||||
...(statusFilterWarning ? { status_filter_warning: statusFilterWarning } : {}),
|
||||
...(groups ? { groups } : {}),
|
||||
applied_filters: {
|
||||
account_from: accountFrom ?? null,
|
||||
|
||||
Reference in New Issue
Block a user