From 17a9346fc496834ffc4060eb6cd7197ab3cc86de Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sat, 8 Aug 2026 23:43:37 +0530 Subject: [PATCH 01/11] feat(clients): group all-client rows by optional label Persist each operator-owned label in versioned JSON under Tauri's ordinary app-config directory, keyed only by the selected company GUID. This keeps the filing decision outside the SQLCipher mirror: loading labels receives no LazyTallyMirror state and cannot resolve a keychain key. Missing, blank, corrupt, unsupported-version, or unreadable config degrades to no labels; blank labels remove their entry. On Unix the file is mode 0600. All Clients now renders a subtotal only for each explicit label and leaves ungrouped companies as individual rows. The cross-company grand-total reduce, its markup, aria label, and styles are removed. Mutation proof: changing the grouped overdue expectation from 120400.5 to 120400.51 made client-grouping.test.mjs fail with actual 120400.5; restored code passes. The test also compares the complete grouped company row as JSON bytes to prove labels do not alter any company figure. Migration: the optional client-group-labels-v1.json file is created on first save; no mirror schema or migration changes. Security: group-label commands use only app_config_dir and filesystem I/O, with no keychain or Tally access. --- scripts/client-grouping.test.mjs | 42 ++++++++ src-tauri/src/client_groups.rs | 111 +++++++++++++++++++ src-tauri/src/commands.rs | 39 ++++++- src-tauri/src/lib.rs | 23 ++++ src/AllClientsScreen.tsx | 178 +++++++++++++++++++++---------- src/client-grouping.ts | 54 ++++++++++ src/styles.css | 80 +++++++++++--- 7 files changed, 452 insertions(+), 75 deletions(-) create mode 100644 scripts/client-grouping.test.mjs create mode 100644 src-tauri/src/client_groups.rs create mode 100644 src/client-grouping.ts diff --git a/scripts/client-grouping.test.mjs b/scripts/client-grouping.test.mjs new file mode 100644 index 0000000..e422864 --- /dev/null +++ b/scripts/client-grouping.test.mjs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import test from "node:test"; + +import { groupClientRows } from "../src/client-grouping.ts"; + +test("applying a group label preserves every company figure byte-for-byte", () => { + const row = { + companyGuid: "synthetic-company-guid", + company: "Synthetic Components Ltd", + receivable: 482001.25, + overdue: 120400.5, + unallocated: 7580.75, + oldest: 97, + complete: true, + }; + const before = Buffer.from(JSON.stringify(row)); + + const grouped = groupClientRows([row], { "synthetic-company-guid": "North practice" }); + const groupedRow = grouped.groups[0].rows[0]; + + assert.deepEqual(Buffer.from(JSON.stringify(groupedRow)), before); + assert.deepEqual(grouped.groups[0].totals, { + receivable: 482001.25, + overdue: 120400.5, + unallocated: 7580.75, + }); + assert.equal(grouped.ungroupedRows.length, 0); +}); + +test("ungrouped companies remain separate and receive no synthetic total", () => { + const rows = [ + { companyGuid: "synthetic-a", receivable: 10, overdue: 2, unallocated: 1 }, + { companyGuid: "synthetic-b", receivable: 20, overdue: 3, unallocated: 4 }, + ]; + + const grouped = groupClientRows(rows, {}); + + assert.deepEqual(grouped.groups, []); + assert.equal(grouped.ungroupedRows.length, 2); +}); diff --git a/src-tauri/src/client_groups.rs b/src-tauri/src/client_groups.rs new file mode 100644 index 0000000..7abebed --- /dev/null +++ b/src-tauri/src/client_groups.rs @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 + +//! Operator-owned company filing labels. +//! +//! These labels deliberately live in the ordinary application configuration +//! directory, not in the encrypted Tally mirror. They are an operator's filing +//! choice rather than accounting data, and reading them must never resolve the +//! mirror key or prompt the operating-system keychain. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::path::Path; + +const FILE_NAME: &str = "client-group-labels-v1.json"; +const SCHEMA_VERSION: u8 = 1; + +pub type ClientGroupLabels = BTreeMap; + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ClientGroupLabelsFile { + version: u8, + labels: ClientGroupLabels, +} + +pub fn load(directory: &Path) -> ClientGroupLabels { + let Ok(contents) = std::fs::read_to_string(directory.join(FILE_NAME)) else { + return ClientGroupLabels::new(); + }; + if contents.trim().is_empty() { + return ClientGroupLabels::new(); + } + + let Ok(file) = serde_json::from_str::(&contents) else { + return ClientGroupLabels::new(); + }; + if file.version != SCHEMA_VERSION { + return ClientGroupLabels::new(); + } + + normalize(file.labels) +} + +pub fn save_label(directory: &Path, company_guid: &str, label: &str) -> Result<(), std::io::Error> { + let mut labels = load(directory); + let company_guid = company_guid.trim(); + let label = label.trim(); + if label.is_empty() { + labels.remove(company_guid); + } else { + labels.insert(company_guid.to_string(), label.to_string()); + } + + std::fs::create_dir_all(directory)?; + let path = directory.join(FILE_NAME); + let contents = serde_json::to_vec_pretty(&ClientGroupLabelsFile { + version: SCHEMA_VERSION, + labels, + }) + .expect("client group label schema always serializes"); + std::fs::write(&path, contents)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + Ok(()) +} + +fn normalize(labels: ClientGroupLabels) -> ClientGroupLabels { + labels + .into_iter() + .filter_map(|(company_guid, label)| { + let company_guid = company_guid.trim(); + let label = label.trim(); + (!company_guid.is_empty() && !label.is_empty()) + .then(|| (company_guid.to_string(), label.to_string())) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_empty_and_corrupt_files_mean_no_groups() { + let directory = tempfile::tempdir().expect("temporary config directory"); + assert!(load(directory.path()).is_empty()); + + std::fs::write(directory.path().join(FILE_NAME), " \n").expect("write empty file"); + assert!(load(directory.path()).is_empty()); + + std::fs::write(directory.path().join(FILE_NAME), "not json").expect("write corrupt file"); + assert!(load(directory.path()).is_empty()); + } + + #[test] + fn labels_survive_a_reload_and_blank_labels_remove_the_group() { + let directory = tempfile::tempdir().expect("temporary config directory"); + save_label(directory.path(), "synthetic-company-guid", "North practice") + .expect("save label"); + assert_eq!( + load(directory.path()).get("synthetic-company-guid"), + Some(&"North practice".to_string()) + ); + + save_label(directory.path(), "synthetic-company-guid", " ").expect("remove label"); + assert!(load(directory.path()).is_empty()); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 6447c90..762493b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,3 +1,4 @@ +use crate::client_groups; use crate::db::tally_incremental::IncrementalFoundationEvidence; use crate::db::tally_mirror::{ company_profile_correlation_key, selected_read_scope_commitment_sha256, CapabilityItemInput, @@ -40,7 +41,7 @@ use bridge_tally_core::{ }; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use tauri::State; +use tauri::{AppHandle, Manager, State}; use zeroize::Zeroizing; const MAX_DSC_PIN_BYTES: usize = 128; @@ -2107,6 +2108,42 @@ pub async fn fetch_tally_outstandings( .map_err(tally_runtime_command_error) } +/// Reads operator-owned filing labels from ordinary application configuration. +/// +/// The helper deliberately degrades to no labels for a missing, empty, corrupt, +/// or unavailable file. It receives no mirror state, so this command cannot +/// initialise SQLCipher or resolve a keychain key. +#[tauri::command] +pub fn load_client_group_labels(app: AppHandle) -> client_groups::ClientGroupLabels { + let Ok(directory) = app.path().app_config_dir() else { + return client_groups::ClientGroupLabels::new(); + }; + client_groups::load(&directory) +} + +#[derive(Debug, Deserialize)] +pub struct SaveClientGroupLabelRequest { + pub company_guid: String, + pub label: String, +} + +/// Saves one operator-owned filing label without accessing the Tally mirror. +#[tauri::command] +pub fn save_client_group_label( + app: AppHandle, + request: SaveClientGroupLabelRequest, +) -> Result<(), String> { + if request.company_guid.trim().is_empty() { + return Err("Bridge could not identify the company for this group label.".to_string()); + } + let directory = app + .path() + .app_config_dir() + .map_err(|_| "Bridge could not locate its local group-label configuration.".to_string())?; + client_groups::save_label(&directory, &request.company_guid, &request.label) + .map_err(|_| "Bridge could not save this group label.".to_string()) +} + #[derive(Debug, Deserialize)] pub struct AllCompaniesOutstandingsRequest { pub config: TallyConfig, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ad69e4e..1a4282f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ pub mod axal; +pub mod client_groups; pub mod commands; pub mod db; pub mod documents; @@ -94,6 +95,8 @@ pub fn run() { commands::preview_bulk_party_statements, commands::export_bulk_party_statements, commands::fetch_tally_outstandings_all_companies, + commands::load_client_group_labels, + commands::save_client_group_label, commands::detect_tally_base_currency, commands::tally_persisted_company_profiles, commands::tally_mirror_explorer_page, @@ -230,3 +233,23 @@ mod security_config_tests { assert!(csp.contains("default-src 'none'")); } } + +#[cfg(test)] +mod client_group_label_mount_tests { + #[test] + fn group_label_load_path_is_mirror_and_keychain_free() { + let commands = include_str!("commands.rs"); + let start = commands + .find("pub fn load_client_group_labels") + .expect("group-label load command"); + let end = commands[start..] + .find("pub struct AllCompaniesOutstandingsRequest") + .map(|offset| start + offset) + .expect("end of group-label commands"); + let group_label_commands = &commands[start..end]; + + assert!(group_label_commands.contains("app_config_dir")); + assert!(!group_label_commands.contains("LazyTallyMirror")); + assert!(!group_label_commands.contains("keyring")); + } +} diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx index f0e4653..3529f7c 100644 --- a/src/AllClientsScreen.tsx +++ b/src/AllClientsScreen.tsx @@ -3,6 +3,7 @@ import React from "react"; import { ChevronRight, RefreshCw } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; +import { ClientGroupLabels, groupClientRows } from "./client-grouping"; import { outstandingsPartialState } from "./outstandings-copy"; type CompanyRef = { name: string; guid: string }; @@ -74,8 +75,26 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P const [entries, setEntries] = React.useState(null); const [loading, setLoading] = React.useState(false); const [error, setError] = React.useState(null); + const [groupLabels, setGroupLabels] = React.useState({}); + const [groupLabelError, setGroupLabelError] = React.useState(null); const requestVersion = React.useRef(0); + React.useEffect(() => { + let active = true; + void invoke("load_client_group_labels") + .then((labels) => { + if (active) setGroupLabels(labels); + }) + // A label is optional. If its local config cannot be read, continue as + // ungrouped instead of turning the report into a failed screen. + .catch(() => { + if (active) setGroupLabels({}); + }); + return () => { + active = false; + }; + }, []); + const load = React.useCallback(async () => { if (companies.length === 0) return; const version = requestVersion.current + 1; @@ -113,6 +132,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P return entries .map((entry) => { const complete = entry.result.state === "complete" ? entry.result : null; + const companyGuid = companies.find((company) => company.name === entry.company)?.guid ?? entry.company; const oldest = complete ? complete.report.top_parties.reduce( (worst, party) => @@ -124,6 +144,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P : null; return { company: entry.company, + companyGuid, complete, reasonCode: entry.result.state === "partial" ? entry.result.reason_code : null, receivable: complete ? amountOf(complete.report.receivable_total) : 0, @@ -156,18 +177,73 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P }); }, [entries, sort]); - const totals = React.useMemo(() => rows.reduce( - (sum, row) => ({ - receivable: sum.receivable + row.receivable, - overdue: sum.overdue + row.overdue, - unallocated: sum.unallocated + row.unallocated, - }), - { receivable: 0, overdue: 0, unallocated: 0 }, - ), [rows]); + const groupedRows = React.useMemo( + () => groupClientRows(rows, groupLabels), + [rows, groupLabels], + ); const readable = rows.filter((row) => row.complete).length; const largestExposure = Math.max(...rows.map((row) => row.receivable + row.unallocated), 0); + const updateGroupLabel = React.useCallback((companyGuid: string, label: string) => { + setGroupLabels((current) => { + const next = { ...current }; + if (label.trim()) next[companyGuid] = label; + else delete next[companyGuid]; + return next; + }); + }, []); + + const saveGroupLabel = React.useCallback((companyGuid: string, label: string) => { + setGroupLabelError(null); + void invoke("save_client_group_label", { + request: { company_guid: companyGuid, label }, + }).catch(() => setGroupLabelError("Bridge could not save this group label. Your figures are unchanged.")); + }, []); + + const renderRow = (row: (typeof rows)[number]) => { + const partial = row.reasonCode ? outstandingsPartialState(row.reasonCode) : null; + return ( + + ); + }; + return (
@@ -210,11 +286,27 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P {entries && rows.length > 0 && ( <> -
-
Receivable{formatCompact(String(totals.receivable))}
-
Overdue 90+{formatCompact(String(totals.overdue))}
-
Unallocated{formatCompact(String(totals.unallocated))}
-
+
+
+

Client groups

+

Optional filing labels. Ungrouped clients stay separate.

+
+
+ {companies.map((company) => ( + + ))} +
+ {groupLabelError &&

{groupLabelError}

} +
@@ -234,59 +326,27 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P onClick={() => setSort((current) => current.key === key ? { key, desc: !current.desc } - // Money and age default to worst-first; a name defaults - // to A-Z, because "descending name" is never what is - // wanted on first click. : { key, desc: key !== "client" })} > {label} ))}
- {rows.map((row) => { - const partial = row.reasonCode ? outstandingsPartialState(row.reasonCode) : null; - return ( - - ); - })} + {formatCompact(String(group.totals.unallocated))} + +
+ {group.rows.map(renderRow)} + + ))} + {groupedRows.ungroupedRows.map(renderRow)}
)} diff --git a/src/client-grouping.ts b/src/client-grouping.ts new file mode 100644 index 0000000..d36b878 --- /dev/null +++ b/src/client-grouping.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: Apache-2.0 + +export type ClientGroupLabels = Record; + +export type GroupableClientRow = { + companyGuid: string; + receivable: number; + overdue: number; + unallocated: number; +}; + +export type ClientGroup = { + label: string; + rows: Row[]; + totals: { receivable: number; overdue: number; unallocated: number }; +}; + +function totalRows(rows: readonly GroupableClientRow[]) { + return rows.reduce( + (total, row) => ({ + receivable: total.receivable + row.receivable, + overdue: total.overdue + row.overdue, + unallocated: total.unallocated + row.unallocated, + }), + { receivable: 0, overdue: 0, unallocated: 0 }, + ); +} + +/// Groups only labeled rows. Ungrouped companies stay as individual rows so +/// the screen never presents a synthetic catch-all total. +export function groupClientRows( + rows: readonly Row[], + labels: ClientGroupLabels, +): { groups: ClientGroup[]; ungroupedRows: Row[] } { + const grouped = new Map(); + const ungroupedRows: Row[] = []; + + for (const row of rows) { + const label = labels[row.companyGuid]?.trim(); + if (!label) { + ungroupedRows.push(row); + continue; + } + const group = grouped.get(label) ?? []; + group.push(row); + grouped.set(label, group); + } + + const groups = [...grouped.entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([label, groupRows]) => ({ label, rows: groupRows, totals: totalRows(groupRows) })); + + return { groups, ungroupedRows }; +} diff --git a/src/styles.css b/src/styles.css index ac9b9e2..ecd0518 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2255,33 +2255,53 @@ button.outstandings-party { background: #fff; } -.clients-totals { +.client-group-labels { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + grid-template-columns: minmax(180px, 0.7fr) minmax(0, 2fr); + gap: 20px 30px; + padding: 20px 30px; border-bottom: 1px solid #e6ebe7; + background: #fbfcfb; } -.clients-totals div { - padding: 20px 30px; +.client-group-labels h3, +.client-group-labels p { + margin: 0; } -.clients-totals div + div { - border-left: 1px solid #e6ebe7; +.client-group-labels h3 { + color: #16241c; + font-size: 14px; } -.clients-totals span { - display: block; +.client-group-labels p, +.client-group-label-grid label > span { color: #61756a; - font-size: 13px; + font-size: 12px; +} + +.client-group-labels p { + margin-top: 4px; +} + +.client-group-label-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; } -.clients-totals strong { +.client-group-label-grid label > span { display: block; - margin-top: 8px; - font-size: 26px; - letter-spacing: -0.02em; - color: #16241c; - font-variant-numeric: tabular-nums; + margin-bottom: 4px; +} + +.client-group-label-grid input { + width: 100%; +} + +.client-group-label-error { + grid-column: 1 / -1; + color: #a13f32 !important; } .clients-table { @@ -2313,6 +2333,20 @@ button.outstandings-party { cursor: default; } +.clients-group-total { + min-height: 48px; + margin-top: 10px; + padding: 6px 10px; + border-radius: 7px; + background: #eef5f0; + color: #1f4f36; + cursor: default; +} + +.clients-group-total .clients-name em { + color: #587968; +} + .clients-row:not(.is-head):hover { background: #f7faf8; } @@ -2412,3 +2446,19 @@ button.outstandings-party { .clients-row:hover .clients-age svg { color: #1f6a4a; } + +@media (max-width: 520px) { + .client-group-labels { + grid-template-columns: 1fr; + padding: 20px; + } + + .client-group-label-grid { + grid-template-columns: 1fr; + } + + .clients-table { + padding-right: 20px; + padding-left: 20px; + } +} From f6744d00cbb0e4a9eaebf2c024302ac01e60b47a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Sun, 9 Aug 2026 08:25:36 +0530 Subject: [PATCH 02/11] fix(clients): fail closed on amounts and identity Mutation proof: disabling exact aggregation, rendering a dash for an invalid amount, resolving company GUID from a name, and replacing atomic rename with deletion each made focused Node or Rust checks fail (RC 1/101). Each guard is restored. --- scripts/client-grouping.test.mjs | 47 ++++++++++++++++---- src-tauri/src/client_groups.rs | 76 +++++++++++++++++++++++++++++++- src-tauri/src/commands.rs | 2 + src/AllClientsScreen.tsx | 60 ++++++++++++++++--------- src/OutstandingsScreen.tsx | 10 ++++- src/client-grouping.ts | 52 +++++++++++++++++----- 6 files changed, 200 insertions(+), 47 deletions(-) diff --git a/scripts/client-grouping.test.mjs b/scripts/client-grouping.test.mjs index e422864..69d0179 100644 --- a/scripts/client-grouping.test.mjs +++ b/scripts/client-grouping.test.mjs @@ -1,17 +1,16 @@ // SPDX-License-Identifier: Apache-2.0 import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; import test from "node:test"; -import { groupClientRows } from "../src/client-grouping.ts"; +import { groupClientRows, sumExactDecimals } from "../src/client-grouping.ts"; test("applying a group label preserves every company figure byte-for-byte", () => { const row = { companyGuid: "synthetic-company-guid", company: "Synthetic Components Ltd", - receivable: 482001.25, - overdue: 120400.5, - unallocated: 7580.75, + exactAmounts: { receivable: "482001.25", overdue: "120400.5", unallocated: "7580.75" }, oldest: 97, complete: true, }; @@ -22,17 +21,17 @@ test("applying a group label preserves every company figure byte-for-byte", () = assert.deepEqual(Buffer.from(JSON.stringify(groupedRow)), before); assert.deepEqual(grouped.groups[0].totals, { - receivable: 482001.25, - overdue: 120400.5, - unallocated: 7580.75, + receivable: "482001.25", + overdue: "120400.5", + unallocated: "7580.75", }); assert.equal(grouped.ungroupedRows.length, 0); }); test("ungrouped companies remain separate and receive no synthetic total", () => { const rows = [ - { companyGuid: "synthetic-a", receivable: 10, overdue: 2, unallocated: 1 }, - { companyGuid: "synthetic-b", receivable: 20, overdue: 3, unallocated: 4 }, + { companyGuid: "synthetic-a", exactAmounts: { receivable: "10", overdue: "2", unallocated: "1" } }, + { companyGuid: "synthetic-b", exactAmounts: { receivable: "20", overdue: "3", unallocated: "4" } }, ]; const grouped = groupClientRows(rows, {}); @@ -40,3 +39,33 @@ test("ungrouped companies remain separate and receive no synthetic total", () => assert.deepEqual(grouped.groups, []); assert.equal(grouped.ungroupedRows.length, 2); }); + +test("group totals preserve decimal precision without IEEE-754 rounding", () => { + assert.equal(sumExactDecimals(["9007199254740993", "0.01", "-1"]), "9007199254740992.01"); + assert.equal(sumExactDecimals(["42.00", "0.10"]), "42.1"); + assert.equal(sumExactDecimals(["10", "not-an-amount"]), undefined); +}); + +test("client amount views fail visibly instead of coercing or flipping amounts", async () => { + const [allClients, outstandings] = await Promise.all([ + readFile(new URL("../src/AllClientsScreen.tsx", import.meta.url), "utf8"), + readFile(new URL("../src/OutstandingsScreen.tsx", import.meta.url), "utf8"), + ]); + + assert.match(allClients, /Amount unavailable/); + assert.doesNotMatch(allClients, /Math\.abs/); + assert.match(outstandings, /Bridge could not read an outstandings amount/); + assert.doesNotMatch(outstandings, /Math\.abs/); +}); + +test("all-client responses carry the pinned GUID back to the open action", async () => { + const [allClients, commands] = await Promise.all([ + readFile(new URL("../src/AllClientsScreen.tsx", import.meta.url), "utf8"), + readFile(new URL("../src-tauri/src/commands.rs", import.meta.url), "utf8"), + ]); + + assert.match(commands, /pub company_guid: String/); + assert.match(commands, /company_guid: entry\.expected_company_guid/); + assert.match(allClients, /companyGuid: entry\.company_guid/); + assert.doesNotMatch(allClients, /companies\.find\(\(company\) => company\.name === entry\.company\)/); +}); diff --git a/src-tauri/src/client_groups.rs b/src-tauri/src/client_groups.rs index 7abebed..3aac916 100644 --- a/src-tauri/src/client_groups.rs +++ b/src-tauri/src/client_groups.rs @@ -9,7 +9,8 @@ use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; -use std::path::Path; +use std::io::Write; +use std::path::{Path, PathBuf}; const FILE_NAME: &str = "client-group-labels-v1.json"; const SCHEMA_VERSION: u8 = 1; @@ -58,7 +59,7 @@ pub fn save_label(directory: &Path, company_guid: &str, label: &str) -> Result<( labels, }) .expect("client group label schema always serializes"); - std::fs::write(&path, contents)?; + write_file_atomically(&path, &contents)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -67,6 +68,54 @@ pub fn save_label(directory: &Path, company_guid: &str, label: &str) -> Result<( Ok(()) } +/// Replacing the label file is a single rename after a fully written sibling +/// exists. A process interruption can therefore retain the previous labels or +/// the complete next labels, never a truncated JSON file that loads as none. +fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<(), std::io::Error> { + let parent = path + .parent() + .ok_or_else(|| std::io::Error::other("missing_label_parent"))?; + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| std::io::Error::other("invalid_label_file_name"))?; + for sequence in 1..=10_000_u32 { + let temporary = temporary_label_path(parent, file_name, sequence); + let mut file = match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => return Err(error), + }; + if let Err(error) = file.write_all(contents).and_then(|_| file.sync_all()) { + drop(file); + let _ = std::fs::remove_file(&temporary); + return Err(error); + } + drop(file); + if let Err(error) = std::fs::rename(&temporary, path) { + let _ = std::fs::remove_file(&temporary); + return Err(error); + } + return Ok(()); + } + Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "label_temporary_name_exhausted", + )) +} + +fn temporary_label_path(parent: &Path, file_name: &str, sequence: u32) -> PathBuf { + parent.join(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + sequence + )) +} + fn normalize(labels: ClientGroupLabels) -> ClientGroupLabels { labels .into_iter() @@ -108,4 +157,27 @@ mod tests { save_label(directory.path(), "synthetic-company-guid", " ").expect("remove label"); assert!(load(directory.path()).is_empty()); } + + #[test] + fn replacement_writes_leave_only_a_complete_current_label_file() { + let directory = tempfile::tempdir().expect("temporary config directory"); + save_label(directory.path(), "synthetic-company-guid", "North practice") + .expect("first label"); + save_label(directory.path(), "synthetic-company-guid", "South practice") + .expect("replacement label"); + + assert_eq!( + load(directory.path()).get("synthetic-company-guid"), + Some(&"South practice".to_string()) + ); + assert!(directory + .path() + .read_dir() + .expect("directory entries") + .all(|entry| !entry + .expect("directory entry") + .file_name() + .to_string_lossy() + .ends_with(".tmp"))); + } } diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 762493b..5c2bf43 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2160,6 +2160,7 @@ pub struct AllCompaniesEntry { #[derive(Debug, Serialize)] pub struct CompanyOutstandingsEntry { pub company: String, + pub company_guid: String, pub result: OutstandingsLoadResult, } @@ -2236,6 +2237,7 @@ pub async fn fetch_tally_outstandings_all_companies( }; entries.push(CompanyOutstandingsEntry { company: entry.company, + company_guid: entry.expected_company_guid, result: company_sweep_result(result), }); } diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx index 3529f7c..10ebc0c 100644 --- a/src/AllClientsScreen.tsx +++ b/src/AllClientsScreen.tsx @@ -29,11 +29,12 @@ type LoadResult = | { state: "complete"; report: Report; unallocated_total?: string } | { state: "partial"; reason_code: string }; -type Entry = { company: string; result: LoadResult }; +type Entry = { company: string; company_guid: string; result: LoadResult }; function amountOf(value: string | undefined) { - const parsed = Number.parseFloat(value ?? "0"); - return Number.isFinite(parsed) ? Math.abs(parsed) : 0; + if (!value || !/^-?\d+(?:\.\d+)?$/.test(value)) return null; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; } function formatMoney(value: string) { @@ -52,6 +53,7 @@ function formatMoney(value: string) { /// screen, and in the export. function formatCompact(value: string | undefined) { const amount = amountOf(value); + if (amount === null) return "Amount unavailable"; if (amount === 0) return "—"; if (amount >= 10_000_000) return `₹${(amount / 10_000_000).toFixed(2)} cr`; if (amount >= 100_000) return `₹${(amount / 100_000).toFixed(2)} L`; @@ -132,7 +134,6 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P return entries .map((entry) => { const complete = entry.result.state === "complete" ? entry.result : null; - const companyGuid = companies.find((company) => company.name === entry.company)?.guid ?? entry.company; const oldest = complete ? complete.report.top_parties.reduce( (worst, party) => @@ -144,19 +145,26 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P : null; return { company: entry.company, - companyGuid, + companyGuid: entry.company_guid, complete, reasonCode: entry.result.state === "partial" ? entry.result.reason_code : null, - receivable: complete ? amountOf(complete.report.receivable_total) : 0, - overdue: complete ? amountOf(complete.report.ageing.days_90_plus) : 0, - unallocated: complete ? amountOf(complete.unallocated_total) : 0, + receivable: complete ? amountOf(complete.report.receivable_total) : null, + overdue: complete ? amountOf(complete.report.ageing.days_90_plus) : null, + unallocated: complete ? amountOf(complete.unallocated_total) : null, + exactAmounts: { + receivable: complete ? complete.report.receivable_total : undefined, + overdue: complete ? complete.report.ageing.days_90_plus : undefined, + unallocated: complete ? complete.unallocated_total : undefined, + }, // How much of this book's exposure Tally cannot age. It is the // single best signal of whether the other numbers can be trusted, // and it varies enormously between books. - unallocatedShare: complete && complete.unallocated_total !== undefined + unallocatedShare: complete + && amountOf(complete.unallocated_total) !== null + && amountOf(complete.report.receivable_total) !== null ? Math.round( - amountOf(complete.unallocated_total) - / Math.max(1, amountOf(complete.unallocated_total) + amountOf(complete.report.receivable_total)) + amountOf(complete.unallocated_total)! + / Math.max(1, amountOf(complete.unallocated_total)! + amountOf(complete.report.receivable_total)!) * 100, ) : null, @@ -173,7 +181,12 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P const r = right.oldest ?? -1; return (l - r) * direction; } - return (left[sort.key] - right[sort.key]) * direction; + const leftAmount = left[sort.key]; + const rightAmount = right[sort.key]; + if (leftAmount === null && rightAmount === null) return 0; + if (leftAmount === null) return 1; + if (rightAmount === null) return -1; + return (leftAmount - rightAmount) * direction; }); }, [entries, sort]); @@ -183,7 +196,10 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P ); const readable = rows.filter((row) => row.complete).length; - const largestExposure = Math.max(...rows.map((row) => row.receivable + row.unallocated), 0); + const largestExposure = Math.max( + ...rows.map((row) => row.receivable === null || row.unallocated === null ? 0 : row.receivable + row.unallocated), + 0, + ); const updateGroupLabel = React.useCallback((companyGuid: string, label: string) => { setGroupLabels((current) => { @@ -218,7 +234,7 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P readable without comparing five columns of digits. */} 0 ? Math.max(1, (row.receivable + row.unallocated) / largestExposure * 100) : 0}%` }} + style={{ width: `${largestExposure > 0 && row.receivable !== null && row.unallocated !== null ? Math.max(1, (row.receivable + row.unallocated) / largestExposure * 100) : 0}%` }} aria-hidden="true" /> @@ -229,11 +245,11 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P {row.unallocatedShare}% carries no bill reference )} - {row.complete ? formatCompact(String(row.receivable)) : "—"} - 0 ? "is-overdue" : undefined}> - {row.complete ? formatCompact(String(row.overdue)) : "—"} + {row.complete ? formatCompact(row.exactAmounts.receivable) : "—"} + 0 ? "is-overdue" : undefined}> + {row.complete ? formatCompact(row.exactAmounts.overdue) : "—"} - {row.complete ? formatCompact(String(row.unallocated)) : "—"} + {row.complete ? formatCompact(row.exactAmounts.unallocated) : "—"} {row.oldest === null ? none @@ -336,11 +352,11 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P
{group.label}Group total - {formatCompact(String(group.totals.receivable))} - 0 ? "is-overdue" : undefined}> - {formatCompact(String(group.totals.overdue))} + {formatCompact(group.totals.receivable)} + 0 ? "is-overdue" : undefined}> + {formatCompact(group.totals.overdue)} - {formatCompact(String(group.totals.unallocated))} + {formatCompact(group.totals.unallocated)}
{group.rows.map(renderRow)} diff --git a/src/OutstandingsScreen.tsx b/src/OutstandingsScreen.tsx index 1ed4e16..69d344d 100644 --- a/src/OutstandingsScreen.tsx +++ b/src/OutstandingsScreen.tsx @@ -797,8 +797,14 @@ function comparePartiesBy(sort: PartySort) { } function amountOf(value: string) { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? Math.abs(parsed) : 0; + if (!/^-?\d+(?:\.\d+)?$/.test(value)) { + throw new Error("Bridge could not read an outstandings amount."); + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error("Bridge could not read an outstandings amount."); + } + return parsed; } /// Ageing rows carry their own bar width. Widths are relative to the LARGEST diff --git a/src/client-grouping.ts b/src/client-grouping.ts index d36b878..58f8762 100644 --- a/src/client-grouping.ts +++ b/src/client-grouping.ts @@ -4,26 +4,54 @@ export type ClientGroupLabels = Record; export type GroupableClientRow = { companyGuid: string; - receivable: number; - overdue: number; - unallocated: number; + exactAmounts: { + receivable: string | undefined; + overdue: string | undefined; + unallocated: string | undefined; + }; }; export type ClientGroup = { label: string; rows: Row[]; - totals: { receivable: number; overdue: number; unallocated: number }; + totals: { receivable: string | undefined; overdue: string | undefined; unallocated: string | undefined }; }; function totalRows(rows: readonly GroupableClientRow[]) { - return rows.reduce( - (total, row) => ({ - receivable: total.receivable + row.receivable, - overdue: total.overdue + row.overdue, - unallocated: total.unallocated + row.unallocated, - }), - { receivable: 0, overdue: 0, unallocated: 0 }, - ); + return { + receivable: sumExactDecimals(rows.map((row) => row.exactAmounts.receivable)), + overdue: sumExactDecimals(rows.map((row) => row.exactAmounts.overdue)), + unallocated: sumExactDecimals(rows.map((row) => row.exactAmounts.unallocated)), + }; +} + +type ExactParts = { negative: boolean; whole: string; fraction: string }; + +function parseExactDecimal(value: string | undefined): ExactParts | undefined { + const match = value?.match(/^(-?)(\d+)(?:\.(\d+))?$/); + if (!match) return undefined; + return { negative: match[1] === "-", whole: match[2], fraction: match[3] ?? "" }; +} + +/// Adds source decimal strings with `BigInt`, never through IEEE-754. If any +/// row is not a valid exact decimal, the group total is unavailable rather than +/// rounded into a believable figure. +export function sumExactDecimals(values: readonly (string | undefined)[]): string | undefined { + const parsed = values.map(parseExactDecimal); + if (parsed.some((value) => value === undefined)) return undefined; + const exact = parsed as ExactParts[]; + const scale = Math.max(...exact.map((value) => value.fraction.length), 0); + const total = exact.reduce((sum, value) => { + const digits = `${value.whole}${value.fraction.padEnd(scale, "0")}`; + const scaled = BigInt(digits) * (value.negative ? -1n : 1n); + return sum + scaled; + }, 0n); + const negative = total < 0n; + const unsigned = (negative ? -total : total).toString().padStart(scale + 1, "0"); + const whole = scale === 0 ? unsigned : unsigned.slice(0, -scale); + const fraction = scale === 0 ? "" : unsigned.slice(-scale).replace(/0+$/, ""); + if (whole === "0" && !fraction) return "0"; + return `${negative ? "-" : ""}${whole}${fraction ? `.${fraction}` : ""}`; } /// Groups only labeled rows. Ungrouped companies stay as individual rows so From b9a350ec35104e46dc20607a2ed588d7d3627dcd Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 17 Aug 2026 18:42:32 +0530 Subject: [PATCH 03/11] chore(tally): reseal compatibility surface for F1X Signed-off-by: Tapish Khandelwal --- .../compatibility/compatibility-surface.json | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index a97b36e..f2deb5c 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -63,7 +63,7 @@ }, { "path": "docs/tally/compatibility/synthetic-write-canary-fixture.md", - "sha256": "9ed85e60adb7306f11496b47f5a86d49327abfdd8e2735678c521187b9ce76e4" + "sha256": "dd2f1c68c0925523af1468dd9c61330433130c0e72b7c713dfc1ef9205b4756f" }, { "path": "docs/tally/support-matrix.md", @@ -79,7 +79,7 @@ }, { "path": "scripts/outstandings-copy.test.mjs", - "sha256": "8c9adb100a45704ca8ba72203a397defca3e8b01a297e96043c543cc052b980a" + "sha256": "58ca3cb255a8e54dc3a4590146b908b800fd4186d474eb05c3e1a46ae6c005a0" }, { "path": "scripts/tally-company-selection.test.mjs", @@ -95,7 +95,7 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "7aac9a6af2fdab6e117af7d3aa26ba04434701fe02d3010010fa3f3793236212" + "sha256": "59012d808bcc29d0abe31d74c6f97d7afe2aa6acd33ec188fabf61d8b92f10cc" }, { "path": "src-tauri/Cargo.toml", @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs", - "sha256": "e1c9082a214a125454c2bbddef8494283d41efa025e4acfc1525f0a22aa2bd1e" + "sha256": "c5c61049fdbedf31cfafb43d349961e736eb7c6a361ec08e410f2f94099b1200" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/completeness.rs", @@ -175,7 +175,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs", - "sha256": "616eb8fa5e387bff74f62d8e775b7eb118860763b18ff63ad88ad5015f7f752f" + "sha256": "65da6e2a0cf543c46e1834d82f836b2966f9f574952b991259179cf743f907d6" }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs", @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "d12c341e5f5fecd6e41436293699bd8f318f0ae2127ccd7ede29e0af2db9d19e" + "sha256": "b4ebca3ae8da37ab144575c9520e83d2acd1b7c78149ac3eb2012801a3e9aabc" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -303,7 +303,7 @@ }, { "path": "src-tauri/src/lib.rs", - "sha256": "62c950aacaff7b1b431b65f32e14ead421d87a0bd3cd44a580912adfb5bf3730" + "sha256": "5582994d94163235a885bce0d44eadc11b6d8eb9a36898610e94c2b1141efbae" }, { "path": "src-tauri/src/sync/coordinator.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "1d2ae890a240344ad9a2a59b4624be2726d13c11e5e529a0b084e355f865deec" + "sha256": "4a4cb2bc46cfcadbb082550c8f24e735d8bfb9206f524d6bee4db8bbf69ef95d" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "5fbb3bc7a47487cf5a5d9f5bc527c5a7e01940589fd519c4a07c1e6c6f094cf3" + "sha256": "479aff297f63050a4903154e565062f774057bec2b9c452b81c5934782f9ea5e" }, { "path": "src/TallyReadinessFlow.tsx", @@ -371,7 +371,7 @@ }, { "path": "src/outstandings-copy.ts", - "sha256": "a2bcf820f694443ac122e91aa98fb354b6e2d5c6be3e1e9503b368a3a0a4f696" + "sha256": "0e78dd97d79c0166fe17a4e933733c369a7e4be3046ddbe3e38122ad5e234216" }, { "path": "src/outstandings-csv.ts", @@ -379,7 +379,7 @@ }, { "path": "src/styles.css", - "sha256": "b488d5305557b4c356e4d2e16971d387724e9be17ede1d8c05213f64c9661146" + "sha256": "e567fc5c8afebc047a8f8d59c7c7399dfefcb5e018aa337004fabf1ad8f03e4f" }, { "path": "src/tally-company-selection.ts", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "29d49a8d8cf10c3415c7d487dfb7419e69ea581f035ec1b3d82788f2ea25f25e" + "manifest_sha256": "" } From 558700740742a2d7342c15a0fc1f73f655eb60e7 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Mon, 17 Aug 2026 22:10:38 +0530 Subject: [PATCH 04/11] fix(clients): refuse destructive label rewrites Keep display reads resilient, but make update reads typed and fallible: only an absent file becomes an empty label map. Empty, unreadable, corrupt, and unsupported-version files now reject save before any write. Red before fix: corrupt and future-schema raw-byte tests both overwrote the source and exited 101. Mutation proof routing save back through the lossy display loader exited 101 for both cases. Restored client_groups suite: 6 passed, exit 0; cargo fmt check exit 0. --- src-tauri/src/client_groups.rs | 127 +++++++++++++++++++++++++++++---- 1 file changed, 114 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/client_groups.rs b/src-tauri/src/client_groups.rs index 3aac916..f84588c 100644 --- a/src-tauri/src/client_groups.rs +++ b/src-tauri/src/client_groups.rs @@ -24,26 +24,54 @@ struct ClientGroupLabelsFile { labels: ClientGroupLabels, } +#[derive(Debug, thiserror::Error)] +pub enum ClientGroupLabelsError { + #[error("client group label file could not be read")] + Read(#[source] std::io::Error), + #[error("client group label file is empty")] + EmptyFile, + #[error("client group label file is corrupt")] + CorruptFile(#[source] serde_json::Error), + #[error("client group label schema version {found} is unsupported; expected {supported}")] + UnsupportedVersion { found: u8, supported: u8 }, + #[error("client group label file could not be written")] + Write(#[source] std::io::Error), +} + pub fn load(directory: &Path) -> ClientGroupLabels { - let Ok(contents) = std::fs::read_to_string(directory.join(FILE_NAME)) else { - return ClientGroupLabels::new(); + try_load(directory).unwrap_or_default() +} + +fn try_load(directory: &Path) -> Result { + let contents = match std::fs::read_to_string(directory.join(FILE_NAME)) { + Ok(contents) => contents, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(ClientGroupLabels::new()) + } + Err(error) => return Err(ClientGroupLabelsError::Read(error)), }; if contents.trim().is_empty() { - return ClientGroupLabels::new(); + return Err(ClientGroupLabelsError::EmptyFile); } - let Ok(file) = serde_json::from_str::(&contents) else { - return ClientGroupLabels::new(); - }; + let file = serde_json::from_str::(&contents) + .map_err(ClientGroupLabelsError::CorruptFile)?; if file.version != SCHEMA_VERSION { - return ClientGroupLabels::new(); + return Err(ClientGroupLabelsError::UnsupportedVersion { + found: file.version, + supported: SCHEMA_VERSION, + }); } - normalize(file.labels) + Ok(normalize(file.labels)) } -pub fn save_label(directory: &Path, company_guid: &str, label: &str) -> Result<(), std::io::Error> { - let mut labels = load(directory); +pub fn save_label( + directory: &Path, + company_guid: &str, + label: &str, +) -> Result<(), ClientGroupLabelsError> { + let mut labels = try_load(directory)?; let company_guid = company_guid.trim(); let label = label.trim(); if label.is_empty() { @@ -52,18 +80,19 @@ pub fn save_label(directory: &Path, company_guid: &str, label: &str) -> Result<( labels.insert(company_guid.to_string(), label.to_string()); } - std::fs::create_dir_all(directory)?; + std::fs::create_dir_all(directory).map_err(ClientGroupLabelsError::Write)?; let path = directory.join(FILE_NAME); let contents = serde_json::to_vec_pretty(&ClientGroupLabelsFile { version: SCHEMA_VERSION, labels, }) .expect("client group label schema always serializes"); - write_file_atomically(&path, &contents)?; + write_file_atomically(&path, &contents).map_err(ClientGroupLabelsError::Write)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(ClientGroupLabelsError::Write)?; } Ok(()) } @@ -180,4 +209,76 @@ mod tests { .to_string_lossy() .ends_with(".tmp"))); } + + #[test] + fn save_refuses_to_overwrite_a_corrupt_existing_label_file() { + let directory = tempfile::tempdir().expect("temporary config directory"); + let path = directory.path().join(FILE_NAME); + let original = b"{not valid json"; + std::fs::write(&path, original).expect("write corrupt label bytes"); + + assert!( + load(directory.path()).is_empty(), + "display remains available" + ); + assert!(matches!( + save_label(directory.path(), "new-synthetic-guid", "New practice"), + Err(ClientGroupLabelsError::CorruptFile(_)) + )); + assert_eq!( + std::fs::read(path).expect("read preserved label bytes"), + original + ); + } + + #[test] + fn save_refuses_to_overwrite_an_unsupported_label_schema() { + let directory = tempfile::tempdir().expect("temporary config directory"); + let path = directory.path().join(FILE_NAME); + let original = br#"{"version":2,"labels":{"existing-guid":"Existing practice"}}"#; + std::fs::write(&path, original).expect("write future label schema"); + + assert!( + load(directory.path()).is_empty(), + "display remains available" + ); + assert!(matches!( + save_label(directory.path(), "new-synthetic-guid", "New practice"), + Err(ClientGroupLabelsError::UnsupportedVersion { + found: 2, + supported: SCHEMA_VERSION + }) + )); + assert_eq!( + std::fs::read(path).expect("read preserved label bytes"), + original + ); + } + + #[test] + fn save_refuses_empty_or_unreadable_existing_label_paths() { + let empty_directory = tempfile::tempdir().expect("temporary config directory"); + let empty_path = empty_directory.path().join(FILE_NAME); + std::fs::write(&empty_path, b" \n").expect("write empty label file"); + assert!(load(empty_directory.path()).is_empty()); + assert!(matches!( + save_label(empty_directory.path(), "new-synthetic-guid", "New practice"), + Err(ClientGroupLabelsError::EmptyFile) + )); + assert_eq!(std::fs::read(empty_path).expect("read empty bytes"), b" \n"); + + let unreadable_directory = tempfile::tempdir().expect("temporary config directory"); + let unreadable_path = unreadable_directory.path().join(FILE_NAME); + std::fs::create_dir(&unreadable_path).expect("create unreadable label path"); + assert!(load(unreadable_directory.path()).is_empty()); + assert!(matches!( + save_label( + unreadable_directory.path(), + "new-synthetic-guid", + "New practice" + ), + Err(ClientGroupLabelsError::Read(_)) + )); + assert!(unreadable_path.is_dir()); + } } From c31fe8d8682a95764538b0f7b446060b50892fb6 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 05:49:44 +0530 Subject: [PATCH 05/11] chore(tally): reseal F4X PR8 compatibility surface Carry the PR8 client-grouping surface over the F4X BILLREF presentation disclosure. Claims, evidence, and trusted-evidence keys remain byte-identical to the preserved PR8 head. Compatibility gate: exit 0, unknown_claims=11, evidenced_claims=0. Claims/evidence/trusted identity checks: exit 0. --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index a443c21..64ceeca 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "29d49a8d8cf10c3415c7d487dfb7419e69ea581f035ec1b3d82788f2ea25f25e", + "compatibility_surface_sha256": "75585d305c4eb3d7762523b9ed16e6f22a03b77c6e37f20d6386b7fd5f8ec0cc", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index f2deb5c..0d3a332 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "4a4cb2bc46cfcadbb082550c8f24e735d8bfb9206f524d6bee4db8bbf69ef95d" + "sha256": "f6c4a1507b00c2689c128be4991af14af7cbd3ad2cec684673bebff9bb24a3c9" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "" + "manifest_sha256": "75585d305c4eb3d7762523b9ed16e6f22a03b77c6e37f20d6386b7fd5f8ec0cc" } From 1e5b8abc7ad9a3b7dae27d459daac65c5746479a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 11:37:01 +0530 Subject: [PATCH 06/11] fix(ui): roll back failed group label saves Track the last persisted label set and revert only the optimistic value whose save failed. A later edit is left intact, while a failed first save returns the company to ungrouped.Red before fix: the focused test failed to import the missing rollback contract (exit 1). Mutation proof: returning the optimistic state unchanged failed with North vs Original (5 passed, 1 failed; exit 1).Verified: client grouping suite 6/6, Impeccable deterministic detector clean, and pnpm build 48/48 plus TypeScript and Vite, exit 0. Node 26.3.0 emitted the repository's expected unsupported-engine warning against >=22.12 <25. --- scripts/client-grouping.test.mjs | 23 +++++++++++++++++- src/AllClientsScreen.tsx | 41 +++++++++++++++++++++++--------- src/client-grouping.ts | 24 +++++++++++++++++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/scripts/client-grouping.test.mjs b/scripts/client-grouping.test.mjs index 69d0179..6805f40 100644 --- a/scripts/client-grouping.test.mjs +++ b/scripts/client-grouping.test.mjs @@ -4,7 +4,28 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { groupClientRows, sumExactDecimals } from "../src/client-grouping.ts"; +import { applyClientGroupLabel, groupClientRows, rollbackFailedClientGroupLabel, sumExactDecimals } from "../src/client-grouping.ts"; + +test("a failed optimistic label save restores only the value that actually failed", () => { + const persisted = { "synthetic-company-guid": "Original" }; + const optimistic = applyClientGroupLabel(persisted, "synthetic-company-guid", "North"); + assert.deepEqual( + rollbackFailedClientGroupLabel(optimistic, "synthetic-company-guid", "North", persisted), + persisted, + ); + + const newerEdit = applyClientGroupLabel(optimistic, "synthetic-company-guid", "Newer edit"); + assert.deepEqual( + rollbackFailedClientGroupLabel(newerEdit, "synthetic-company-guid", "North", persisted), + newerEdit, + "a late failure must not erase typing that happened after the failed attempt", + ); + assert.deepEqual( + rollbackFailedClientGroupLabel({ "synthetic-company-guid": "North" }, "synthetic-company-guid", "North", {}), + {}, + "a failed first save returns the company to ungrouped", + ); +}); test("applying a group label preserves every company figure byte-for-byte", () => { const row = { diff --git a/src/AllClientsScreen.tsx b/src/AllClientsScreen.tsx index 10ebc0c..638790b 100644 --- a/src/AllClientsScreen.tsx +++ b/src/AllClientsScreen.tsx @@ -3,7 +3,7 @@ import React from "react"; import { ChevronRight, RefreshCw } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; -import { ClientGroupLabels, groupClientRows } from "./client-grouping"; +import { applyClientGroupLabel, ClientGroupLabels, groupClientRows, rollbackFailedClientGroupLabel } from "./client-grouping"; import { outstandingsPartialState } from "./outstandings-copy"; type CompanyRef = { name: string; guid: string }; @@ -79,18 +79,25 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P const [error, setError] = React.useState(null); const [groupLabels, setGroupLabels] = React.useState({}); const [groupLabelError, setGroupLabelError] = React.useState(null); + const persistedGroupLabels = React.useRef({}); const requestVersion = React.useRef(0); React.useEffect(() => { let active = true; void invoke("load_client_group_labels") .then((labels) => { - if (active) setGroupLabels(labels); + if (active) { + persistedGroupLabels.current = labels; + setGroupLabels(labels); + } }) // A label is optional. If its local config cannot be read, continue as // ungrouped instead of turning the report into a failed screen. .catch(() => { - if (active) setGroupLabels({}); + if (active) { + persistedGroupLabels.current = {}; + setGroupLabels({}); + } }); return () => { active = false; @@ -202,19 +209,31 @@ export function AllClientsScreen({ config, companies, onOpenCompany, onBack }: P ); const updateGroupLabel = React.useCallback((companyGuid: string, label: string) => { - setGroupLabels((current) => { - const next = { ...current }; - if (label.trim()) next[companyGuid] = label; - else delete next[companyGuid]; - return next; - }); + setGroupLabels((current) => applyClientGroupLabel(current, companyGuid, label)); }, []); const saveGroupLabel = React.useCallback((companyGuid: string, label: string) => { + const attemptedLabel = label.trim(); setGroupLabelError(null); void invoke("save_client_group_label", { - request: { company_guid: companyGuid, label }, - }).catch(() => setGroupLabelError("Bridge could not save this group label. Your figures are unchanged.")); + request: { company_guid: companyGuid, label: attemptedLabel }, + }) + .then(() => { + persistedGroupLabels.current = applyClientGroupLabel( + persistedGroupLabels.current, + companyGuid, + attemptedLabel, + ); + }) + .catch(() => { + setGroupLabels((current) => rollbackFailedClientGroupLabel( + current, + companyGuid, + attemptedLabel, + persistedGroupLabels.current, + )); + setGroupLabelError("Bridge could not save this group label. The previous label was restored; your figures are unchanged."); + }); }, []); const renderRow = (row: (typeof rows)[number]) => { diff --git a/src/client-grouping.ts b/src/client-grouping.ts index 58f8762..110e4e9 100644 --- a/src/client-grouping.ts +++ b/src/client-grouping.ts @@ -2,6 +2,30 @@ export type ClientGroupLabels = Record; +export function applyClientGroupLabel( + labels: ClientGroupLabels, + companyGuid: string, + label: string, +): ClientGroupLabels { + const next = { ...labels }; + const normalized = label.trim(); + if (normalized) next[companyGuid] = normalized; + else delete next[companyGuid]; + return next; +} + +export function rollbackFailedClientGroupLabel( + current: ClientGroupLabels, + companyGuid: string, + attemptedLabel: string, + persisted: ClientGroupLabels, +): ClientGroupLabels { + if ((current[companyGuid] ?? "").trim() !== attemptedLabel.trim()) { + return current; + } + return applyClientGroupLabel(current, companyGuid, persisted[companyGuid] ?? ""); +} + export type GroupableClientRow = { companyGuid: string; exactAmounts: { From e53f60b13f83597a9c960ca33d229a3c8f063891 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 11:56:51 +0530 Subject: [PATCH 07/11] chore(tally): reseal F5X PR8 compatibility surface --- .../compatibility/compatibility-matrix.json | 2 +- .../compatibility/compatibility-surface.json | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 64ceeca..bde466d 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "75585d305c4eb3d7762523b9ed16e6f22a03b77c6e37f20d6386b7fd5f8ec0cc", + "compatibility_surface_sha256": "f09ac40bd4edf459edb618c42df7a39fffb7b42f7ecf48c15a9a3724bc3fac9f", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 0d3a332..0fd0dfa 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -63,7 +63,7 @@ }, { "path": "docs/tally/compatibility/synthetic-write-canary-fixture.md", - "sha256": "dd2f1c68c0925523af1468dd9c61330433130c0e72b7c713dfc1ef9205b4756f" + "sha256": "9ed85e60adb7306f11496b47f5a86d49327abfdd8e2735678c521187b9ce76e4" }, { "path": "docs/tally/support-matrix.md", @@ -79,7 +79,7 @@ }, { "path": "scripts/outstandings-copy.test.mjs", - "sha256": "58ca3cb255a8e54dc3a4590146b908b800fd4186d474eb05c3e1a46ae6c005a0" + "sha256": "8c9adb100a45704ca8ba72203a397defca3e8b01a297e96043c543cc052b980a" }, { "path": "scripts/tally-company-selection.test.mjs", @@ -143,7 +143,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs", - "sha256": "c5c61049fdbedf31cfafb43d349961e736eb7c6a361ec08e410f2f94099b1200" + "sha256": "e1c9082a214a125454c2bbddef8494283d41efa025e4acfc1525f0a22aa2bd1e" }, { "path": "src-tauri/crates/bridge-tally-protocol/src/outstandings/completeness.rs", @@ -175,7 +175,7 @@ }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs", - "sha256": "65da6e2a0cf543c46e1834d82f836b2966f9f574952b991259179cf743f907d6" + "sha256": "616eb8fa5e387bff74f62d8e775b7eb118860763b18ff63ad88ad5015f7f752f" }, { "path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs", @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "b4ebca3ae8da37ab144575c9520e83d2acd1b7c78149ac3eb2012801a3e9aabc" + "sha256": "0386381576aac2c6efcb4fa87d8a6bf983e6046c06b9a728436cd8b1784457fa" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "f6c4a1507b00c2689c128be4991af14af7cbd3ad2cec684673bebff9bb24a3c9" + "sha256": "ac652a70cd3b0b7184733ea08f6d8967f5b25f357d171d96a5c6d96f818d0563" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -359,7 +359,7 @@ }, { "path": "src/OutstandingsScreen.tsx", - "sha256": "479aff297f63050a4903154e565062f774057bec2b9c452b81c5934782f9ea5e" + "sha256": "6835d23a31eb92e1037e8f1e9c268ea86c59554a6b7060009e4dfa086bfa03d6" }, { "path": "src/TallyReadinessFlow.tsx", @@ -371,7 +371,7 @@ }, { "path": "src/outstandings-copy.ts", - "sha256": "0e78dd97d79c0166fe17a4e933733c369a7e4be3046ddbe3e38122ad5e234216" + "sha256": "a2bcf820f694443ac122e91aa98fb354b6e2d5c6be3e1e9503b368a3a0a4f696" }, { "path": "src/outstandings-csv.ts", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "75585d305c4eb3d7762523b9ed16e6f22a03b77c6e37f20d6386b7fd5f8ec0cc" + "manifest_sha256": "f09ac40bd4edf459edb618c42df7a39fffb7b42f7ecf48c15a9a3724bc3fac9f" } From 24754c122ce25ae5725987e583271a3735da6b2a Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 12:10:57 +0530 Subject: [PATCH 08/11] chore(tally): reseal F5X PR8 after clippy repair --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index bde466d..18b6191 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "f09ac40bd4edf459edb618c42df7a39fffb7b42f7ecf48c15a9a3724bc3fac9f", + "compatibility_surface_sha256": "7ea8b0b85a4d42e61e4ce757dbb9a4a51f14f911376dbdca391d3592e325c381", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 0fd0dfa..171ab07 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "ac652a70cd3b0b7184733ea08f6d8967f5b25f357d171d96a5c6d96f818d0563" + "sha256": "893bf325dcd614d49ac661127333c0cf714a937776c609a442fbccc240d06da1" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "f09ac40bd4edf459edb618c42df7a39fffb7b42f7ecf48c15a9a3724bc3fac9f" + "manifest_sha256": "7ea8b0b85a4d42e61e4ce757dbb9a4a51f14f911376dbdca391d3592e325c381" } From 1daef38b63eda56e34c3f1398bd0973fd601de87 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 12:34:45 +0530 Subject: [PATCH 09/11] chore(tally): reseal F5X PR8 after PR6 clippy repair --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 18b6191..f13ab75 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "7ea8b0b85a4d42e61e4ce757dbb9a4a51f14f911376dbdca391d3592e325c381", + "compatibility_surface_sha256": "dbc291f1951893d3b55b9f71b8124ff3cd99a73acff484c8167a448beeaad2cb", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index 171ab07..bbbfa02 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -219,7 +219,7 @@ }, { "path": "src-tauri/src/commands.rs", - "sha256": "0386381576aac2c6efcb4fa87d8a6bf983e6046c06b9a728436cd8b1784457fa" + "sha256": "052b45f94751a01a1b7960cd861d1c122b49f0b80088b0434fc33e958396992d" }, { "path": "src-tauri/src/db/encrypted.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "7ea8b0b85a4d42e61e4ce757dbb9a4a51f14f911376dbdca391d3592e325c381" + "manifest_sha256": "dbc291f1951893d3b55b9f71b8124ff3cd99a73acff484c8167a448beeaad2cb" } From 808e0575ecf4c788cf4257fb5a8fe30e13a54545 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Tue, 18 Aug 2026 12:41:02 +0530 Subject: [PATCH 10/11] chore(tally): reseal F5X PR8 after PR7 compile repair --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index f13ab75..c1b1cc3 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "dbc291f1951893d3b55b9f71b8124ff3cd99a73acff484c8167a448beeaad2cb", + "compatibility_surface_sha256": "6b8b8ab6ed23d0e70d44b79bf497e23a1ebc32422911b19cdc213cac7dcd6c82", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index bbbfa02..a0ddca3 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -339,7 +339,7 @@ }, { "path": "src-tauri/src/tally/runtime.rs", - "sha256": "893bf325dcd614d49ac661127333c0cf714a937776c609a442fbccc240d06da1" + "sha256": "1d2ae890a240344ad9a2a59b4624be2726d13c11e5e529a0b084e355f865deec" }, { "path": "src-tauri/src/tally/serial_queue.rs", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "dbc291f1951893d3b55b9f71b8124ff3cd99a73acff484c8167a448beeaad2cb" + "manifest_sha256": "6b8b8ab6ed23d0e70d44b79bf497e23a1ebc32422911b19cdc213cac7dcd6c82" } From f7f6fda3fabc47b385ba9d5571ba5cf6b2aaf545 Mon Sep 17 00:00:00 2001 From: Tapish Khandelwal Date: Wed, 19 Aug 2026 00:35:17 +0530 Subject: [PATCH 11/11] chore(tally): reseal RB1 PR8 compatibility surface Carry the h2 0.4.16 master dependency surface through the PR8 stack level. Claims, evidence, and trusted keys are unchanged. Compatibility gate: exit 0, unknown_claims=11, evidenced_claims=0. --- docs/tally/compatibility/compatibility-matrix.json | 2 +- docs/tally/compatibility/compatibility-surface.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index c1b1cc3..9d3936a 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "6b8b8ab6ed23d0e70d44b79bf497e23a1ebc32422911b19cdc213cac7dcd6c82", + "compatibility_surface_sha256": "ac5d44785fa061e2694384d5ad5fe8f6a001ceb3226119404a00cba6cc7c8684", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index a0ddca3..f6fb0d8 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -95,7 +95,7 @@ }, { "path": "src-tauri/Cargo.lock", - "sha256": "59012d808bcc29d0abe31d74c6f97d7afe2aa6acd33ec188fabf61d8b92f10cc" + "sha256": "7aac9a6af2fdab6e117af7d3aa26ba04434701fe02d3010010fa3f3793236212" }, { "path": "src-tauri/Cargo.toml", @@ -434,5 +434,5 @@ "sha256": "a27f294ee15e407b69fdfc73609e8708ac0509b6e6a8872daef5451fde61a8db" } ], - "manifest_sha256": "6b8b8ab6ed23d0e70d44b79bf497e23a1ebc32422911b19cdc213cac7dcd6c82" + "manifest_sha256": "ac5d44785fa061e2694384d5ad5fe8f6a001ceb3226119404a00cba6cc7c8684" }