diff --git a/apps/frontend/src/lib/api/admin.ts b/apps/frontend/src/lib/api/admin.ts
index a642ede43..c3b859003 100644
--- a/apps/frontend/src/lib/api/admin.ts
+++ b/apps/frontend/src/lib/api/admin.ts
@@ -21,7 +21,9 @@ export interface DbTableStat {
table_name: string;
live_rows: string;
dead_rows: string;
+ last_vacuum: string | null;
last_autovacuum: string | null;
+ last_analyze: string | null;
last_autoanalyze: string | null;
size: string;
size_bytes: string;
diff --git a/apps/frontend/src/pages/DbMaintenancePage.tsx b/apps/frontend/src/pages/DbMaintenancePage.tsx
index dbc7e91f0..b1870a43a 100644
--- a/apps/frontend/src/pages/DbMaintenancePage.tsx
+++ b/apps/frontend/src/pages/DbMaintenancePage.tsx
@@ -140,10 +140,10 @@ function TableStatRow({
- {fmt(row.last_autovacuum)}
+ {fmt(latest(row.last_vacuum, row.last_autovacuum))}
- {fmt(row.last_autoanalyze)}
+ {fmt(latest(row.last_analyze, row.last_autoanalyze))}
{row.size}
@@ -177,6 +177,12 @@ function TableStatRow({
);
}
+/** The later of a manual and an automatic run; either may be NULL. */
+function latest(manual: string | null, auto: string | null): string | null {
+ if (!manual || !auto) return manual ?? auto;
+ return Date.parse(manual) >= Date.parse(auto) ? manual : auto;
+}
+
// ── Page ──────────────────────────────────────────────────────────────────────
export default function DbMaintenancePage() {
diff --git a/apps/frontend/src/pages/__tests__/DbMaintenancePage.integration.test.tsx b/apps/frontend/src/pages/__tests__/DbMaintenancePage.integration.test.tsx
index 37b17696f..bf03b3d2d 100644
--- a/apps/frontend/src/pages/__tests__/DbMaintenancePage.integration.test.tsx
+++ b/apps/frontend/src/pages/__tests__/DbMaintenancePage.integration.test.tsx
@@ -25,7 +25,9 @@ const statsWithTable = {
table_name: "transactions",
live_rows: "1234",
dead_rows: "5",
+ last_vacuum: null,
last_autovacuum: "2025-01-01T00:00:00.000Z",
+ last_analyze: null,
last_autoanalyze: "2025-01-01T00:00:00.000Z",
size: "4096 kB",
size_bytes: "4194304",
diff --git a/apps/frontend/src/types/generated.ts b/apps/frontend/src/types/generated.ts
index 1db62c65e..d4541515e 100644
--- a/apps/frontend/src/types/generated.ts
+++ b/apps/frontend/src/types/generated.ts
@@ -5186,7 +5186,11 @@ export interface components {
live_rows: string;
/** @description BIGINT estimate from pg_stat_user_tables, as decimal text */
dead_rows: string;
+ /** @description Last manual VACUUM (pg_stat_user_tables.last_vacuum as text) */
+ last_vacuum: string | null;
last_autovacuum: string | null;
+ /** @description Last manual ANALYZE (pg_stat_user_tables.last_analyze as text) */
+ last_analyze: string | null;
last_autoanalyze: string | null;
/** @description pg_size_pretty of the total relation size */
size: string;
@@ -17312,7 +17316,7 @@ export interface operations {
};
content?: never;
};
- /** @description Insufficient database privileges to VACUUM */
+ /** @description The database role lacks MAINTAIN on a requested table; nothing is vacuumed */
403: {
headers: {
[name: string]: unknown;
diff --git a/apps/node-backend/src/database/roleBootstrap.ts b/apps/node-backend/src/database/roleBootstrap.ts
index 231208a05..86c9d0f07 100644
--- a/apps/node-backend/src/database/roleBootstrap.ts
+++ b/apps/node-backend/src/database/roleBootstrap.ts
@@ -155,7 +155,9 @@ function renderGrantStatements({
* role can no longer grant privileges on those views and PostgreSQL rejects the
* entire broad GRANT before granting ordinary tables. Expanding the statement
* lets app-owned objects be skipped and isolates any third-party ownership
- * failure to that one relation.
+ * failure to that one relation. Each relation gets only the template
+ * privileges that apply to its kind: a materialized view takes SELECT and
+ * MAINTAIN, and a view or foreign table cannot be maintained.
*
* @returns number of relation-specific grant failures
*/
@@ -178,6 +180,7 @@ const canCreateRowSchema = z
async function grantCurrentRelations(
client: PgOneShotClient,
appRole: string,
+ templatePrivileges: readonly string[],
log: BootstrapLogger,
): Promise {
const result = await client.query(`
@@ -196,8 +199,16 @@ async function grantCurrentRelations(
let failures = 0;
for (const row of checkRows(relationGrantRowSchema, result.rows)) {
if (row.owner_name === appRole) continue;
- const privileges =
- row.relkind === "m" ? "SELECT" : "SELECT, INSERT, UPDATE, DELETE";
+ const applicable =
+ row.relkind === "m"
+ ? ["SELECT", "MAINTAIN"]
+ : row.relkind === "v" || row.relkind === "f"
+ ? ["SELECT", "INSERT", "UPDATE", "DELETE"]
+ : templatePrivileges;
+ const privileges = templatePrivileges
+ .filter((privilege) => applicable.includes(privilege))
+ .join(", ");
+ if (!privileges) continue;
const relation = `${quoteIdent(row.schema_name)}.${quoteIdent(row.relation_name)}`;
try {
await client.query(
@@ -216,8 +227,15 @@ async function grantCurrentRelations(
/** undefined_table, undefined_function, invalid_schema_name */
const UNDEFINED_OBJECT_CODES = new Set(["42P01", "42883", "3F000"]);
-function isAllTablesGrant(statement: string): boolean {
- return /\bON\s+ALL\s+TABLES\s+IN\s+SCHEMA\s+public\b/i.test(statement);
+/** The privilege list of a `GRANT ... ON ALL TABLES IN SCHEMA public`, else null. */
+function allTablesGrantPrivileges(statement: string): string[] | null {
+ const match =
+ /^GRANT\s+(.+?)\s+ON\s+ALL\s+TABLES\s+IN\s+SCHEMA\s+public\b/i.exec(
+ statement,
+ );
+ return match?.[1]
+ ? match[1].split(",").map((privilege) => privilege.trim().toUpperCase())
+ : null;
}
/**
@@ -386,10 +404,12 @@ export async function ensureAppRole({
let grantFailures = 0;
for (const stmt of grantStatements) {
try {
- if (isAllTablesGrant(stmt)) {
+ const tablePrivileges = allTablesGrantPrivileges(stmt);
+ if (tablePrivileges) {
grantFailures += await grantCurrentRelations(
client,
appConn.user,
+ tablePrivileges,
log,
);
continue;
diff --git a/apps/node-backend/src/database/rows/admin.ts b/apps/node-backend/src/database/rows/admin.ts
index d6218ecba..e79eddc99 100644
--- a/apps/node-backend/src/database/rows/admin.ts
+++ b/apps/node-backend/src/database/rows/admin.ts
@@ -15,8 +15,11 @@ export const tableStatsRowSchema = z
table_name: z.string(),
live_rows: pgBigint,
dead_rows: pgBigint,
+ /** `last_vacuum::text`: NULL until the first manual VACUUM. */
+ last_vacuum: z.string().nullable(),
/** `last_autovacuum::text`: NULL until the first autovacuum. */
last_autovacuum: z.string().nullable(),
+ last_analyze: z.string().nullable(),
last_autoanalyze: z.string().nullable(),
/** `pg_size_pretty(...)` */
size: z.string(),
@@ -30,7 +33,7 @@ export const databaseSizeRowSchema = z
.object({ db_size: z.string() })
.describe("database size row");
-/** `SELECT relname FROM pg_stat_user_tables`. */
+/** `pg_stat_user_tables` names with the role's MAINTAIN privilege. */
export const userTableNameRowSchema = z
- .object({ relname: z.string() })
+ .object({ relname: z.string(), can_maintain: z.boolean() })
.describe("user table name row");
diff --git a/apps/node-backend/src/routes/admin.ts b/apps/node-backend/src/routes/admin.ts
index 9482e9b20..339e96456 100644
--- a/apps/node-backend/src/routes/admin.ts
+++ b/apps/node-backend/src/routes/admin.ts
@@ -358,7 +358,9 @@ router.get("/database/stats", async (_req, res) => {
relname AS table_name,
n_live_tup AS live_rows,
n_dead_tup AS dead_rows,
+ last_vacuum::text,
last_autovacuum::text,
+ last_analyze::text,
last_autoanalyze::text,
pg_size_pretty(pg_total_relation_size(relid)) AS size,
pg_total_relation_size(relid) AS size_bytes
@@ -383,10 +385,19 @@ router.get("/database/stats", async (_req, res) => {
router.post("/database/vacuum", adminMutateLimiter, async (req, res) => {
const { table } = parseInput(vacuumBodySchema, req.body ?? {});
- // Validate table name against actual user tables to prevent injection
+ // Validate table name against actual user tables to prevent injection.
+ // PostgreSQL does not fail a VACUUM the role may not run: it logs
+ // "permission denied to vacuum ..., skipping it" as a WARNING and reports
+ // success. Check the MAINTAIN privilege (PostgreSQL 17+) up front so a
+ // skipped table is a 403, never a false success.
const allowed = await checkedRows(
userTableNameRowSchema,
- sql`SELECT relname FROM pg_stat_user_tables WHERE schemaname = 'public'`,
+ sql`
+ SELECT relname, has_table_privilege(relid, 'MAINTAIN') AS can_maintain
+ FROM pg_stat_user_tables
+ WHERE schemaname = 'public'
+ ORDER BY relname
+ `,
);
const allowedNames = new Set(allowed.map((r) => r.relname));
@@ -394,17 +405,29 @@ router.post("/database/vacuum", adminMutateLimiter, async (req, res) => {
throw new ValidationError(`Unknown table: ${table}`);
}
+ const targets = allowed.filter((r) => table == null || r.relname === table);
+ const denied = targets.filter((r) => !r.can_maintain).map((r) => r.relname);
+ if (denied.length > 0) {
+ throw new ForbiddenError(
+ `Insufficient database privileges to VACUUM ${denied.join(", ")}`,
+ );
+ }
+
// VACUUM cannot run inside a transaction block — use raw client
const client = await getClient();
try {
await client.query("SET statement_timeout = 120000");
- // `table` is validated against the allowlist populated from
- // pg_stat_user_tables above and quoted as an identifier. VACUUM rejects
- // parameterized identifiers, so it cannot be a bound parameter.
- const vacuum = compileQuery(
- table ? sql`VACUUM ANALYZE ${sql.id(table)}` : sql`VACUUM ANALYZE`,
- );
- await client.query(vacuum.sql);
+ // Table names come from pg_stat_user_tables above and are quoted as
+ // identifiers. VACUUM rejects parameterized identifiers, so they cannot
+ // be bound parameters. "All" names every public table explicitly: a bare
+ // VACUUM would also try the system catalogs, which only their owner may
+ // vacuum.
+ if (targets.length > 0) {
+ const vacuum = compileQuery(
+ sql`VACUUM ANALYZE ${sql.join(targets.map((r) => sql.id(r.relname)))}`,
+ );
+ await client.query(vacuum.sql);
+ }
} catch (err) {
if (err instanceof Error && "code" in err && err.code === "42501") {
// insufficient_privilege
diff --git a/apps/node-backend/tests/aiAuditRowContracts.db.test.ts b/apps/node-backend/tests/aiAuditRowContracts.db.test.ts
index 6f85f54c6..6d6151def 100644
--- a/apps/node-backend/tests/aiAuditRowContracts.db.test.ts
+++ b/apps/node-backend/tests/aiAuditRowContracts.db.test.ts
@@ -409,6 +409,18 @@ describe.skipIf(!hasTestDatabase())(
.post("/database/vacuum")
.send({ table: "no_such_table_row_contracts" });
expect(rejected.status).toBe(400);
+ const vacuumed = await agent
+ .post("/database/vacuum")
+ .send({ table: "categories" });
+ expect(vacuumed.status).toBe(200);
+ const after = await agent.get("/database/stats");
+ const tables = bodyAt(after, "data", "tables") as {
+ table_name: string;
+ last_vacuum: string | null;
+ }[];
+ expect(
+ tables.find((row) => row.table_name === "categories")?.last_vacuum,
+ ).toEqual(expect.any(String));
});
},
);
diff --git a/apps/node-backend/tests/roleBootstrap.db.test.ts b/apps/node-backend/tests/roleBootstrap.db.test.ts
index 9d6aaff5e..0d281136d 100644
--- a/apps/node-backend/tests/roleBootstrap.db.test.ts
+++ b/apps/node-backend/tests/roleBootstrap.db.test.ts
@@ -143,7 +143,7 @@ describe.skipIf(!hasTestDatabase())("roleBootstrap (real Postgres)", () => {
ownerRole: "own",
dbName: "db",
});
- expect(stmts).toHaveLength(6);
+ expect(stmts).toHaveLength(8);
const joined = stmts.join(";\n");
expect(joined).not.toContain(':"');
expect(joined).toContain('"a""pp"');
@@ -223,6 +223,13 @@ describe.skipIf(!hasTestDatabase())("roleBootstrap (real Postgres)", () => {
"INSERT INTO categories (general, detail) VALUES ('vb_rb', 'probe') RETURNING id, detail",
);
expect(must(inserted[0], "inserted[0]").detail).toBe("probe");
+ // MAINTAIN lets the admin page VACUUM; without it PostgreSQL only warns.
+ const maintain = await queryAs(
+ APP_ROLE,
+ APP_PASS,
+ "SELECT has_table_privilege('categories', 'MAINTAIN') AS ok",
+ );
+ expect(must(maintain[0], "maintain[0]").ok).toBe(true);
const read = await queryAs(
APP_ROLE,
APP_PASS,
@@ -257,6 +264,12 @@ describe.skipIf(!hasTestDatabase())("roleBootstrap (real Postgres)", () => {
"INSERT INTO vb_rb_future_table (v) VALUES ('later') RETURNING id, v",
);
expect(rows[0]).toMatchObject({ id: 1, v: "later" });
+ const maintain = await queryAs(
+ APP_ROLE,
+ APP_PASS,
+ "SELECT has_table_privilege('vb_rb_future_table', 'MAINTAIN') AS ok",
+ );
+ expect(must(maintain[0], "maintain[0]").ok).toBe(true);
await db().query("DROP TABLE vb_rb_future_table");
});
diff --git a/apps/node-backend/tests/routes/adminValidation.test.ts b/apps/node-backend/tests/routes/adminValidation.test.ts
index 381e5f729..c7c36b512 100644
--- a/apps/node-backend/tests/routes/adminValidation.test.ts
+++ b/apps/node-backend/tests/routes/adminValidation.test.ts
@@ -72,7 +72,10 @@ describe("Admin route request validation", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(query).mockResolvedValue({
- rows: [{ relname: "transactions" }],
+ rows: [
+ { relname: "accounts", can_maintain: true },
+ { relname: "transactions", can_maintain: true },
+ ],
} as never);
vi.mocked(getClient).mockResolvedValue(client);
client.query.mockResolvedValue({ rows: [] });
@@ -93,9 +96,34 @@ describe("Admin route request validation", () => {
.post(`${BASE}/database/vacuum`)
.send({ table: null })
.expect(200);
+ expect(client.query).toHaveBeenLastCalledWith(
+ 'VACUUM ANALYZE "accounts", "transactions"',
+ );
expect(bodyAt(all, "data")).toEqual({ vacuumed: "all" });
});
+ it("refuses with 403 instead of a skipped VACUUM when MAINTAIN is missing", async () => {
+ vi.mocked(query).mockResolvedValue({
+ rows: [
+ { relname: "accounts", can_maintain: true },
+ { relname: "transactions", can_maintain: false },
+ ],
+ } as never);
+ for (const table of ["transactions", null]) {
+ const res = await api
+ .post(`${BASE}/database/vacuum`)
+ .send({ table })
+ .expect(403);
+ expect(res.body).toEqual(
+ errEnvelope({
+ code: "FORBIDDEN",
+ message: "Insufficient database privileges to VACUUM transactions",
+ }),
+ );
+ }
+ expect(getClient).not.toHaveBeenCalled();
+ });
+
it("rejects a non-string table and keeps the unknown-table message", async () => {
expectValidationError(
await api
diff --git a/config/postgres/app-role-grants.sql.tpl b/config/postgres/app-role-grants.sql.tpl
index 84649b64e..0a8dc166e 100644
--- a/config/postgres/app-role-grants.sql.tpl
+++ b/config/postgres/app-role-grants.sql.tpl
@@ -10,10 +10,17 @@ GRANT USAGE, CREATE ON SCHEMA public TO :"app_role";
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO :"app_role";
+-- MAINTAIN (PostgreSQL 17+) lets the admin page run VACUUM ANALYZE. Without
+-- it PostgreSQL skips each table with only a warning and reports success.
+GRANT MAINTAIN ON ALL TABLES IN SCHEMA public TO :"app_role";
+
GRANT USAGE, SELECT, UPDATE ON ALL SEQUENCES IN SCHEMA public TO :"app_role";
ALTER DEFAULT PRIVILEGES FOR ROLE :"owner_role" IN SCHEMA public
GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO :"app_role";
+ALTER DEFAULT PRIVILEGES FOR ROLE :"owner_role" IN SCHEMA public
+ GRANT MAINTAIN ON TABLES TO :"app_role";
+
ALTER DEFAULT PRIVILEGES FOR ROLE :"owner_role" IN SCHEMA public
GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO :"app_role";
diff --git a/docs/adr/262-app-role-maintain-for-admin-vacuum.md b/docs/adr/262-app-role-maintain-for-admin-vacuum.md
new file mode 100644
index 000000000..12c9fa420
--- /dev/null
+++ b/docs/adr/262-app-role-maintain-for-admin-vacuum.md
@@ -0,0 +1,65 @@
+---
+title: "ADR-262: App role gets MAINTAIN so admin VACUUM runs"
+type: adr
+status: accepted
+date: 2026-10-29
+tags: [adr, database, postgresql, security, least-privilege, admin, vacuum, adr-113, adr-133]
+description: "The admin page's VACUUM ANALYZE ran as the least-privilege app role, which owns no tables, so PostgreSQL skipped every table with a warning and the route reported success. The app role now holds MAINTAIN on public tables, and the route refuses with 403 when a requested table lacks it. The page shows manual as well as automatic vacuum and analyze times."
+aliases: [adr-262, maintain grant, admin vacuum privilege]
+related_code:
+ [
+ "config/postgres/app-role-grants.sql.tpl",
+ "apps/node-backend/src/database/roleBootstrap.ts",
+ "apps/node-backend/src/routes/admin.ts",
+ "apps/frontend/src/pages/DbMaintenancePage.tsx",
+ ]
+---
+
+# ADR-262: App role gets MAINTAIN so admin VACUUM runs
+
+## Status
+
+Accepted on 2026-10-29. Tor chose "Grant MAINTAIN to the app role" over running VACUUM with the
+migration owner's credentials or only reporting the failure.
+
+## Context
+
+- The backend pool connects as the least-privilege application role
+ ([[docs/adr/113-native-macos-runtime|ADR-113]]). The migration owner owns every table.
+- `POST /api/admin/database/vacuum` ran `VACUUM ANALYZE` (all) or `VACUUM ANALYZE ""` on
+ that pool. PostgreSQL does not fail a VACUUM the role may not run: it emits
+ `WARNING: permission denied to vacuum "", skipping it` and completes. The route's
+ `42501` handler never fired, and the page showed "Vacuum completed". Reproduced on a local
+ PostgreSQL 16 cluster with an owner role and a DML-only app role; PostgreSQL 18 uses the same
+ warn-and-skip path, gated by the `MAINTAIN` privilege.
+- The page showed only `last_autovacuum` and `last_autoanalyze`, so a manual run never changed
+ the visible timestamps even when it did run.
+
+## Decision
+
+- The grant template adds `GRANT MAINTAIN ON ALL TABLES IN SCHEMA public` and a matching default
+ privilege for tables the owner creates later. The bootstrap expands the ALL TABLES grants per
+ relation as before; it now takes the privilege list from the template statement and grants
+ only what applies to the relation kind (materialized views: SELECT and MAINTAIN; views and
+ foreign tables: no MAINTAIN). Existing installs receive the grant on their next launch.
+- The vacuum route reads `has_table_privilege(relid, 'MAINTAIN')` for every public table. If any
+ requested table lacks it, the route returns `403` and runs nothing. "All tables" names every
+ public table explicitly instead of a bare `VACUUM`, which would also try the system catalogs.
+- `GET /api/admin/database/stats` adds `last_vacuum` and `last_analyze`. The page shows the later
+ of the manual and automatic time in each column.
+
+## Consequences
+
+- MAINTAIN also allows ANALYZE, CLUSTER, REINDEX, REFRESH MATERIALIZED VIEW and LOCK TABLE on
+ public tables. It adds no DDL, data or role rights.
+- MAINTAIN exists from PostgreSQL 17. Vision packages only PostgreSQL 18; on an older external
+ server the MAINTAIN grants fail per statement with a logged warning and the route returns 403.
+- Not verified on the Mac. PostgreSQL 18 DB tests run in CI.
+
+## Related
+
+- [[docs/adr/index|All ADRs]]
+- [[docs/features/database-maintenance|Database Maintenance UI]]
+- [[docs/api/admin|Admin API]]
+- [[docs/guides/native-macos-runtime|Native macOS runtime]]
+- [[docs/adr/113-native-macos-runtime|ADR-113: Native macOS runtime]]
diff --git a/docs/adr/index.md b/docs/adr/index.md
index 2ccb7e904..1f78bc27b 100644
--- a/docs/adr/index.md
+++ b/docs/adr/index.md
@@ -2,11 +2,11 @@
title: Architecture Decision Records Index
type: adr-index
status: active
-date: 2026-10-28
-updated: 2026-10-28
-last_modified: 2026-10-28
+date: 2026-10-29
+updated: 2026-10-29
+last_modified: 2026-10-29
tags: [adr, index, architecture, decisions, phase-1, phase-4, phase-5, phase-6, phase-7, security, dependency-slim-down, container-hardening, docker, backup, encryption, aead, aes-256-gcm, codeql, dependabot, rate-limiting, tailwind-v4, css-architecture, dependencies, ai, streaming, useSyncExternalStore, bug-hunt, recovery-hardening, updated-at-constraints, concurrent-backup, ci-cd, secrets-scanning, supply-chain-security, gitleaks, deps-audit, trivy-scan, docker-compose-sync, named-volumes, data-loss, tags, tagging, transaction-tags, orthogonal-dimension, belgian-tax, exemption-brackets, own-home-credits, taxable-income-sources, audit-2026-05-11, disabled-dependents, regional-autonomy-factor, property-tax-centimes, etf-tob, reynders-routing, as-filed, audit-log, comparison, trend-strip, may-2026-audit, monetary-precision, decimal-enforcement, tx-hash-dedup, race-safe-dedup, portfolio-precision, import-precision, forecast-precision, timezone-consistency, snapshot-valuation-parity, fixed-income-accrual, real-estate-appreciation, net-worth-reconciliation, live-overlay, valuation-freshness, price-history, binance, quote-backfill, gap-fill, daily-granularity, sparsity, densify, saved-custom-parsers, custom-parser-configs, named-parsers, bank-label, generic-adapter-fallback, route-service-boundary, lint-enforcement, mv-recipient-monthly-drop, shared-utils, banker-rounding, global-rate-limiter, trusted-proxies, vision-dev, zip-bomb, response-cap, liquid-glass-v2, glass-materials, atmosphere-layer, command-palette, optimistic-updates, route-preload, premium-v3, rolling-number, money-typography, chart-scrub, chart-sync, shader-aurora, enhanced-effects, visual-effects-tiers, auto-adapt-display, fx-reduced, webgl, electron-native, macos, ipc, hiddeninset, vibrancy, system-accent, native-menu, dock-badge, csv-open-with, electronapi, june-2026, fx-attribution, historical-rates, ecb-full-history, purchase-date-rates, asset-gain, fx-gain, value-fx-neutral, portfolio-import, instrument-matching, type-normalizer, kind-discriminator, migration-0040, migration-0041, adr-078, adr-081, adr-082, monte-carlo, portfolio-projection, fundamentals-scorecard, chart-builder, technical-indicators, pillar-c, macro, macroeconomic, fred, dbnomics, eurostat, provider-pinned, db-data-editor, adr-101, xmin, optimistic-concurrency, audit-trail, skin-v2, dense-fintech, visual-redesign, feature-flag, theming, css-scoping, unlayered-css, inline-token-constraint, apple-refined, jewel-emerald, refined-geometry, glass-differentiation, hairlines, tabular-nums, press-feedback, motion-spring, insight-agent, anomaly-agent, detection-layer, narration-layer, insightsDigest, no-external-calls, ollama, native-runtime, postgresql-18, adr-110, adr-111, adr-112, adr-113, recurrence, adr-130]
-description: "Architecture Decision Records documenting significant technical choices and their rationale. 2026-10-28: ADR-255 (cheaper full effects; Full effects is now auto|on|off, default auto — ADR-244's off-by-default boolean is amended), ADR-256 (Electron memory, boot bundle, idle renderer work), ADR-257 (ledger scroll, squircle clips, page transitions), ADR-258 (chart entrances on the compositor, compositor diagnostics), ADR-259 (reduced tier turns glass filters off), ADR-260 (ledger card material on a childless pseudo-element). 2026-10-26: ADR-254 stops the Swift port and removes it from main (ADR-249, 250, 252 superseded; Electron/TypeScript is the only desktop app). 2026-10-25: ADR-246 (Apple Foundation Models through the vision-apple-ai helper). 2026-10-20: ADR-229 removes the OpenAI API route, the Codex experiment and AgentCloak (Ollama is the AI backend). ADR numbers 232, 241 and 243 are intentionally unused. 2026-07-11: ADR-110 NEW (detection-layer/narration-layer split for local-LLM insights — deterministic detection runs auto on page load, local-LLM narration gated behind an explicit click; scheduled background narration rejected as unprompted inference on unknown hardware, scoped to narration only; Statistics-panel + button-badge surfacing, no new dashboard banner/card; server-side pre-call so the model only narrates; ADR-024 no-external-calls guarantee + fetch-spy CI test extended to the narration tool). 2026-07-10: ADR-107 (Accounts budgeting UX remake — one anchor+delta balance definition on every surface with provenance lines, reconcile-as-a-flow, grouped hub, new /accounts/:id running-balance ledger route; look-changing scope user-signed-off) + ADR-108 (Portfolio accounts v2 — whole-lot broker tagging: every lot belongs to one broker account, sells consume same-broker lots, transfers are re-tags carrying basis; per-broker positions AND P&L as parity-tested partitions of the global engine; trade cash legs + moveHoldingService + snapshot value_by_account walk deleted; brokerage cash-statement import path kept and fixed; retires VITE_ENABLE_PER_ACCOUNT_HOLDINGS; supersedes UI scope of ADR-090/091/095/100/103); ADR-088/089/094/103 addenda — accounts-rewrite decisions D1–D5 (normalized implicit minting + case-insensitive identity + lifecycle + contract runbook; multi_currency_cash activated as per-currency balance series; guarded opening-balance anchor via transfer_source='opening'; per-account holdings flag committed to flip default-on as rewrite Phase E behind an 8-item prerequisite gate); round 2 same day D6–D9 — ADR-095 addendum (instrument-less brokerage rows → signed cash row), ADR-060 addendum (NUMERIC(18,4) domain money precision, widen siblings), ADR-109 NEW (flat investments schema canonical, one-time legacy conversion, supersedes ADR-004), D9 recurrence badge-only confirmed (TODO-level). 2026-06-23: ADR-105 (Apple-refined visual pass — bakes refined geometry + jewel accent + differentiated glass shadows into the BASE design, no flag; --radius 0.625rem; Card rounded-[0.75rem]; glass-regular vs glass-elevated shadow differentiation; jewel emerald primary/ring/sidebar-primary/sidebar-ring light 164 78% 26% / dark 160 74% 52%; ease-out-quint + ease-out-expo → cubic-bezier(0.32,0.72,0,1); press-feedback:active scale 0.97; tabular-nums letter-spacing -0.006em; 0.5px hairlines on glass-regular/premium-frame/glass-thin at @media min-resolution:2dppx; aurora/glass/hover/typography kept; VITE_SKIN_V2 gain/loss flag independent and unaffected; label-opacity text hierarchy deferred). ADR-104 (Dense-fintech visual skin behind a flag — ships the \"skin-v2\" redesign as CSS scoped under :root.skin-v2, toggled by VITE_SKIN_V2 (default OFF); applySkinV2Class() called pre-render; localStorage override + window.__setSkinV2 for dev comparison; UNLAYERED CSS wins over Tailwind layers; critical constraint: applyThemePalette() writes color tokens as inline styles — only structural tokens freely overridden from CSS; Phases 0–2 CSS-only implemented; Phase 3 component wiring in progress). 2026-06-20: ADR-103 (Gate per-account holdings UI behind a flag — VITE_ENABLE_PER_ACCOUNT_HOLDINGS default off). 2026-06-18: ADR-101 (Admin DB data editor — JetBrains-style table browser/editor under /admin/db/:table; 3 new admin endpoints (schema/rows/mutate); xmin optimistic concurrency; dryRun SQL preview; db_editor_audit table (migration 0059); matview auto-refresh on transactions/recipients/categories; bypass-domain-validation caveat documented). 2026-06-17: ADR-082 (Macroeconomic Indicators Data Vertical — adds FRED, Eurostat, and DBnomics macro adapters to the research aggregation layer; macro series are provider-pinned, never raced; two new endpoints GET /api/research/macro/search and GET /api/research/macro/series; storage boundary preserved: in-memory cache only, never persisted; keyless degradation to Eurostat catalog + DBnomics when FRED_API_KEY absent; Chart Builder unified search now surfaces Economic data alongside Markets results). 2026-06-16: ADR-081 (Research Analytics & Forecasting Expansion — delivers deferred Pillar C via Monte Carlo portfolio projection engine (drift/risk decoupled: RISK from aggregate NAV daily-return history, DRIFT per-holding blend of historical mean + forward analyst inputs via forwardBlend; parametric and block-bootstrap simulators; non-persisted, seeded PRNG; POST /api/research/portfolio-forecast); deepens Pillar B with freeform Chart Builder at /research/charts (multi-symbol, dual-axis, candlesticks, SMA/EMA/Bollinger/RSI/MACD client-side, presets, localStorage layout); deepens Pillar D with heuristic fundamentals scorecard (0-100/A-F, per-metric flags, missing-field-skip invariant; GET /api/research/scorecard) and extended fundamentals normalization across Yahoo/Finnhub/FMP adapters; adds optional provider param to GET /api/research/chart; openapi.yaml now 192 operations). ADR-079 (Multi-Provider Research Data Aggregation — Research section aggregates Yahoo + Twelve Data + Finnhub + FMP + Alpha Vantage behind priceProviderRegistry; capability map + persisted quota governor (token buckets) + type-aware cache TTLs route around rate limits; research data fetched live, never persisted to asset_price_history; user-confirmed ISIN-anchored cross-provider symbol map is the fool-proof anchor; unlocks pillars A/B strongly, D partially; C deferred designation superseded by ADR-081). 2026-06-15: ADR-078 (Portfolio CSV Import — parallel pipeline over generalizing budgeting import; symbol→name exact matching + mandatory review step for unresolved rows; conservative auto-commit; custom_parser_configs kind discriminator; migrations 0040+0041). 2026-06-12: ADR-075 (Visual-Effects Tiers and Per-Display Auto-Adaptation — replaces ADR-071's enhancedEffects boolean with reduced/standard/enhanced tier + autoAdaptDisplay; large-display heuristic >6M physical px drops tier to reduced automatically; VisualEffectsController tags with fx-reduced/fx-static-atmosphere; ShaderAurora canvas capped at 640px; migration in migrateAppSettings; new settings.appearance.visualEffects*/autoAdaptDisplay* i18n keys, settings.general.enhancedEffects* removed). 2026-06-11: ADR-074 (FX attribution with purchase-date rates — invested capital locks at purchase-date FX rates; total gain = assetGain + fxGain; ECB full-history backfill since 1999; value_fx_neutral snapshot series; fx_rate_to_eur auto-resolved on transaction write). ADR-073 (Shared Portfolio Math in @vision/shared-utils — cost-basis calculators incl. FIFO/LIFO dispatch, accrued interest, buildInvestmentSummaryCore shared by backend service and frontend hooks; cost_basis_method setting wired end-to-end; frontend summaries FX-converted via useExchangeRates). 2026-06-10: ADR-072 (Electron-Native Desktop Integration — hiddenInset traffic lights, native menu bar + dock menu/badge, CSV drag/open-with import handoff, under-window vibrancy behind enhancedEffects toggle, system-accent color overlay, new window.electronAPI contextBridge surface; sandbox posture unchanged). ADR-071 (Premium v3 — RollingNumber/Money/DeltaPill shared components, chart scrub+sync, ChartSkeleton, PageTitleContext large-title collapse, palette v2 + recents, ShortcutsOverlay + go-to key sequences, animated tabs pill, workspace-aware aurora, ShaderAurora behind enhancedEffects toggle [boolean superseded by ADR-075 tier model], light-mode paper&ink tokens, per-widget dashboard hydration, optimistic CREATE). ADR-070 (Liquid Glass v2 — atmosphere layer restored, saturated blur tiers 12–32px, PageTransition re-added as enter-only spring, 45 cards migrated to glass-regular/elevated, CommandPalette, sidebar magic-move ActiveRail, optimistic transaction update/delete, route-chunk hover prefetch). 2026-06-01: ADR-069 (@vision/shared-utils monorepo package; banker's rounding canonical); ADR-068 (drop mv_recipient_monthly — unread MV, write-amplification removed, migration 0038); ADR-067 (enforce route→service boundary — no-repo-direct-from-route promoted to ERROR, 14 new thin service seams). Also June 2026 security hardening: global baseline rate limiter on /api (RATE_LIMIT_GLOBAL_MAX/WINDOW_MS), XFF trusted-proxy gating (TRUSTED_PROXIES), VISION_DEV fail-safe dev bypass, zip-bomb guard on restore, 5 MB response cap on price-provider fetches; snapshotBuilder split/return_of_capital events + APP_TIMEZONE day boundary; portfolioUnitMath.ts shared between Add/Edit dialogs; PlannedPaymentsPage migrated to VirtualDataTable + toast errors; tc() plural function in LanguageContext. Earlier: 2026-06-01 (ADR-066): Saved Named Custom CSV Parsers. 2026-05-31 (ADR-065): Daily gap-fill for dense asset price history. 2026-05-31 (ADR-064): Net Worth current value live overlay. 2026-05-29 (ADR-063): Admin auth token-or-open + CSRF guard supersedes ADR-037 RFC1918 IP-allowlist. May 2026 (ADR-061): Snapshot valuation parity. Prior May 2026: ADR-060 Monetary Precision; ADR-059 Belgian Tax historical year extensions; ADR-058 Belgian Tax historical year snapshots; ADR-057 Belgian Tax follow-up; ADR-056 Belgian Tax audit fixes; ADR-055/054/053 tax correctness; ADR-052 Transaction Tags; ADR-051 Docker Compose sync; ADR-050 CI supply chain; ADR-049 bug hunt; ADR-048 AI Chat stream store; ADR-047 Tailwind v4."
+description: "Architecture Decision Records documenting significant technical choices and their rationale. 2026-10-29: ADR-262 (app role gets MAINTAIN so admin VACUUM runs). 2026-10-28: ADR-255 (cheaper full effects; Full effects is now auto|on|off, default auto — ADR-244's off-by-default boolean is amended), ADR-256 (Electron memory, boot bundle, idle renderer work), ADR-257 (ledger scroll, squircle clips, page transitions), ADR-258 (chart entrances on the compositor, compositor diagnostics), ADR-259 (reduced tier turns glass filters off), ADR-260 (ledger card material on a childless pseudo-element). 2026-10-26: ADR-254 stops the Swift port and removes it from main (ADR-249, 250, 252 superseded; Electron/TypeScript is the only desktop app). 2026-10-25: ADR-246 (Apple Foundation Models through the vision-apple-ai helper). 2026-10-20: ADR-229 removes the OpenAI API route, the Codex experiment and AgentCloak (Ollama is the AI backend). ADR numbers 232, 241 and 243 are intentionally unused. 2026-07-11: ADR-110 NEW (detection-layer/narration-layer split for local-LLM insights — deterministic detection runs auto on page load, local-LLM narration gated behind an explicit click; scheduled background narration rejected as unprompted inference on unknown hardware, scoped to narration only; Statistics-panel + button-badge surfacing, no new dashboard banner/card; server-side pre-call so the model only narrates; ADR-024 no-external-calls guarantee + fetch-spy CI test extended to the narration tool). 2026-07-10: ADR-107 (Accounts budgeting UX remake — one anchor+delta balance definition on every surface with provenance lines, reconcile-as-a-flow, grouped hub, new /accounts/:id running-balance ledger route; look-changing scope user-signed-off) + ADR-108 (Portfolio accounts v2 — whole-lot broker tagging: every lot belongs to one broker account, sells consume same-broker lots, transfers are re-tags carrying basis; per-broker positions AND P&L as parity-tested partitions of the global engine; trade cash legs + moveHoldingService + snapshot value_by_account walk deleted; brokerage cash-statement import path kept and fixed; retires VITE_ENABLE_PER_ACCOUNT_HOLDINGS; supersedes UI scope of ADR-090/091/095/100/103); ADR-088/089/094/103 addenda — accounts-rewrite decisions D1–D5 (normalized implicit minting + case-insensitive identity + lifecycle + contract runbook; multi_currency_cash activated as per-currency balance series; guarded opening-balance anchor via transfer_source='opening'; per-account holdings flag committed to flip default-on as rewrite Phase E behind an 8-item prerequisite gate); round 2 same day D6–D9 — ADR-095 addendum (instrument-less brokerage rows → signed cash row), ADR-060 addendum (NUMERIC(18,4) domain money precision, widen siblings), ADR-109 NEW (flat investments schema canonical, one-time legacy conversion, supersedes ADR-004), D9 recurrence badge-only confirmed (TODO-level). 2026-06-23: ADR-105 (Apple-refined visual pass — bakes refined geometry + jewel accent + differentiated glass shadows into the BASE design, no flag; --radius 0.625rem; Card rounded-[0.75rem]; glass-regular vs glass-elevated shadow differentiation; jewel emerald primary/ring/sidebar-primary/sidebar-ring light 164 78% 26% / dark 160 74% 52%; ease-out-quint + ease-out-expo → cubic-bezier(0.32,0.72,0,1); press-feedback:active scale 0.97; tabular-nums letter-spacing -0.006em; 0.5px hairlines on glass-regular/premium-frame/glass-thin at @media min-resolution:2dppx; aurora/glass/hover/typography kept; VITE_SKIN_V2 gain/loss flag independent and unaffected; label-opacity text hierarchy deferred). ADR-104 (Dense-fintech visual skin behind a flag — ships the \"skin-v2\" redesign as CSS scoped under :root.skin-v2, toggled by VITE_SKIN_V2 (default OFF); applySkinV2Class() called pre-render; localStorage override + window.__setSkinV2 for dev comparison; UNLAYERED CSS wins over Tailwind layers; critical constraint: applyThemePalette() writes color tokens as inline styles — only structural tokens freely overridden from CSS; Phases 0–2 CSS-only implemented; Phase 3 component wiring in progress). 2026-06-20: ADR-103 (Gate per-account holdings UI behind a flag — VITE_ENABLE_PER_ACCOUNT_HOLDINGS default off). 2026-06-18: ADR-101 (Admin DB data editor — JetBrains-style table browser/editor under /admin/db/:table; 3 new admin endpoints (schema/rows/mutate); xmin optimistic concurrency; dryRun SQL preview; db_editor_audit table (migration 0059); matview auto-refresh on transactions/recipients/categories; bypass-domain-validation caveat documented). 2026-06-17: ADR-082 (Macroeconomic Indicators Data Vertical — adds FRED, Eurostat, and DBnomics macro adapters to the research aggregation layer; macro series are provider-pinned, never raced; two new endpoints GET /api/research/macro/search and GET /api/research/macro/series; storage boundary preserved: in-memory cache only, never persisted; keyless degradation to Eurostat catalog + DBnomics when FRED_API_KEY absent; Chart Builder unified search now surfaces Economic data alongside Markets results). 2026-06-16: ADR-081 (Research Analytics & Forecasting Expansion — delivers deferred Pillar C via Monte Carlo portfolio projection engine (drift/risk decoupled: RISK from aggregate NAV daily-return history, DRIFT per-holding blend of historical mean + forward analyst inputs via forwardBlend; parametric and block-bootstrap simulators; non-persisted, seeded PRNG; POST /api/research/portfolio-forecast); deepens Pillar B with freeform Chart Builder at /research/charts (multi-symbol, dual-axis, candlesticks, SMA/EMA/Bollinger/RSI/MACD client-side, presets, localStorage layout); deepens Pillar D with heuristic fundamentals scorecard (0-100/A-F, per-metric flags, missing-field-skip invariant; GET /api/research/scorecard) and extended fundamentals normalization across Yahoo/Finnhub/FMP adapters; adds optional provider param to GET /api/research/chart; openapi.yaml now 192 operations). ADR-079 (Multi-Provider Research Data Aggregation — Research section aggregates Yahoo + Twelve Data + Finnhub + FMP + Alpha Vantage behind priceProviderRegistry; capability map + persisted quota governor (token buckets) + type-aware cache TTLs route around rate limits; research data fetched live, never persisted to asset_price_history; user-confirmed ISIN-anchored cross-provider symbol map is the fool-proof anchor; unlocks pillars A/B strongly, D partially; C deferred designation superseded by ADR-081). 2026-06-15: ADR-078 (Portfolio CSV Import — parallel pipeline over generalizing budgeting import; symbol→name exact matching + mandatory review step for unresolved rows; conservative auto-commit; custom_parser_configs kind discriminator; migrations 0040+0041). 2026-06-12: ADR-075 (Visual-Effects Tiers and Per-Display Auto-Adaptation — replaces ADR-071's enhancedEffects boolean with reduced/standard/enhanced tier + autoAdaptDisplay; large-display heuristic >6M physical px drops tier to reduced automatically; VisualEffectsController tags with fx-reduced/fx-static-atmosphere; ShaderAurora canvas capped at 640px; migration in migrateAppSettings; new settings.appearance.visualEffects*/autoAdaptDisplay* i18n keys, settings.general.enhancedEffects* removed). 2026-06-11: ADR-074 (FX attribution with purchase-date rates — invested capital locks at purchase-date FX rates; total gain = assetGain + fxGain; ECB full-history backfill since 1999; value_fx_neutral snapshot series; fx_rate_to_eur auto-resolved on transaction write). ADR-073 (Shared Portfolio Math in @vision/shared-utils — cost-basis calculators incl. FIFO/LIFO dispatch, accrued interest, buildInvestmentSummaryCore shared by backend service and frontend hooks; cost_basis_method setting wired end-to-end; frontend summaries FX-converted via useExchangeRates). 2026-06-10: ADR-072 (Electron-Native Desktop Integration — hiddenInset traffic lights, native menu bar + dock menu/badge, CSV drag/open-with import handoff, under-window vibrancy behind enhancedEffects toggle, system-accent color overlay, new window.electronAPI contextBridge surface; sandbox posture unchanged). ADR-071 (Premium v3 — RollingNumber/Money/DeltaPill shared components, chart scrub+sync, ChartSkeleton, PageTitleContext large-title collapse, palette v2 + recents, ShortcutsOverlay + go-to key sequences, animated tabs pill, workspace-aware aurora, ShaderAurora behind enhancedEffects toggle [boolean superseded by ADR-075 tier model], light-mode paper&ink tokens, per-widget dashboard hydration, optimistic CREATE). ADR-070 (Liquid Glass v2 — atmosphere layer restored, saturated blur tiers 12–32px, PageTransition re-added as enter-only spring, 45 cards migrated to glass-regular/elevated, CommandPalette, sidebar magic-move ActiveRail, optimistic transaction update/delete, route-chunk hover prefetch). 2026-06-01: ADR-069 (@vision/shared-utils monorepo package; banker's rounding canonical); ADR-068 (drop mv_recipient_monthly — unread MV, write-amplification removed, migration 0038); ADR-067 (enforce route→service boundary — no-repo-direct-from-route promoted to ERROR, 14 new thin service seams). Also June 2026 security hardening: global baseline rate limiter on /api (RATE_LIMIT_GLOBAL_MAX/WINDOW_MS), XFF trusted-proxy gating (TRUSTED_PROXIES), VISION_DEV fail-safe dev bypass, zip-bomb guard on restore, 5 MB response cap on price-provider fetches; snapshotBuilder split/return_of_capital events + APP_TIMEZONE day boundary; portfolioUnitMath.ts shared between Add/Edit dialogs; PlannedPaymentsPage migrated to VirtualDataTable + toast errors; tc() plural function in LanguageContext. Earlier: 2026-06-01 (ADR-066): Saved Named Custom CSV Parsers. 2026-05-31 (ADR-065): Daily gap-fill for dense asset price history. 2026-05-31 (ADR-064): Net Worth current value live overlay. 2026-05-29 (ADR-063): Admin auth token-or-open + CSRF guard supersedes ADR-037 RFC1918 IP-allowlist. May 2026 (ADR-061): Snapshot valuation parity. Prior May 2026: ADR-060 Monetary Precision; ADR-059 Belgian Tax historical year extensions; ADR-058 Belgian Tax historical year snapshots; ADR-057 Belgian Tax follow-up; ADR-056 Belgian Tax audit fixes; ADR-055/054/053 tax correctness; ADR-052 Transaction Tags; ADR-051 Docker Compose sync; ADR-050 CI supply chain; ADR-049 bug hunt; ADR-048 AI Chat stream store; ADR-047 Tailwind v4."
aliases: [ADRs, decisions, architecture decisions]
---
@@ -287,6 +287,13 @@ See [[docs/adr/template\|the ADR template]] for the format to use when creating
> ADR-213 after merging `main`, which already uses ADR-206 to ADR-208 (bank chronology,
> coordinated native recovery, bounded report pipeline).
+### 2026-10-29: App role gets MAINTAIN so admin VACUUM runs (accepted)
+
+[[docs/adr/262-app-role-maintain-for-admin-vacuum|ADR-262]] grants `MAINTAIN` on public tables to
+the least-privilege app role, because the admin page's VACUUM was silently skipped for every table.
+The route returns `403` when a table lacks the privilege, and the page shows manual vacuum and
+analyze times.
+
### 2026-10-28: Ticker and Settings material hosts, 20 fps WebGL aurora (accepted)
[[docs/adr/261-ticker-and-settings-material-hosts-and-20-fps-aurora|ADR-261]] makes the portfolio
diff --git a/docs/api/admin.md b/docs/api/admin.md
index 054bef435..6f3e4f8c2 100644
--- a/docs/api/admin.md
+++ b/docs/api/admin.md
@@ -2,8 +2,8 @@
title: Admin API
type: endpoint
status: active
-date: 2026-10-28
-updated: 2026-10-28
+date: 2026-10-29
+updated: 2026-10-29
tags:
- api
- admin
@@ -204,7 +204,9 @@ Retrieve current database table statistics from `pg_stat_user_tables`, largest t
"table_name": "transactions",
"live_rows": "2453",
"dead_rows": "127",
+ "last_vacuum": null,
"last_autovacuum": "2026-04-24 10:15:32.123456+00",
+ "last_analyze": null,
"last_autoanalyze": null,
"size": "3200 kB",
"size_bytes": "3276800"
@@ -215,7 +217,7 @@ Retrieve current database table statistics from `pg_stat_user_tables`, largest t
```
- `live_rows`, `dead_rows` and `size_bytes` are BIGINT values, so they arrive as decimal strings.
-- `last_autovacuum` and `last_autoanalyze` are `::text` casts, `null` for a table never vacuumed or analyzed.
+- `last_vacuum`, `last_autovacuum`, `last_analyze` and `last_autoanalyze` are `::text` casts, `null` for a table never vacuumed or analyzed. `last_vacuum` and `last_analyze` are manual runs, such as this page's VACUUM ([[docs/adr/262-app-role-maintain-for-admin-vacuum|ADR-262]]).
- `size` and `db_size` are `pg_size_pretty` text.
- Contract change (October 2026, non-breaking): `openapi.yaml` now types the responses of `GET /api/admin/database/stats` (`DbStats`), `GET /api/admin/providers/health` (`ProviderHealthList`), `POST /api/admin/providers/{provider}/probe` (`ProbeResult`), `GET /api/admin/metrics/requests` (`RouteMetricList`) and `GET /api/admin/endpoints` (`EndpointList`) with the shapes the routes already returned and `packages/types/src/contracts/admin.ts` already checked. This page previously described a `table` query parameter and a different stats body that the route never had.
@@ -262,7 +264,10 @@ The route always runs `VACUUM ANALYZE`.
{ "ok": true, "data": { "vacuumed": "transactions" } }
```
-`vacuumed` is `"all"` when no table was given. Insufficient database privileges return `403`.
+`vacuumed` is `"all"` when no table was given; "all" names every public table explicitly.
+The route first checks `has_table_privilege(..., 'MAINTAIN')` for each requested table. If one
+lacks it, the route returns `403` and vacuums nothing, because PostgreSQL itself would only warn
+and skip the table ([[docs/adr/262-app-role-maintain-for-admin-vacuum|ADR-262]]).
**Response:** `409 Conflict` (VACUUM already running)
diff --git a/docs/features/database-maintenance.md b/docs/features/database-maintenance.md
index 8019fa729..79dc21799 100644
--- a/docs/features/database-maintenance.md
+++ b/docs/features/database-maintenance.md
@@ -2,8 +2,8 @@
title: Database Maintenance UI
type: feature
status: active
-date: 2026-10-28
-updated: 2026-10-28
+date: 2026-10-29
+updated: 2026-10-29
tags:
[
feature,
@@ -16,6 +16,7 @@ tags:
db-data-editor,
adr-101,
adr-231,
+ adr-262,
]
description: Administrative interface for monitoring database statistics, running VACUUM ANALYZE operations, and (ADR-101) browsing/editing raw table data with optimistic concurrency, dry-run preview, and a log line per committed change.
aliases:
@@ -145,9 +146,9 @@ The VACUUM operation uses a **raw database client** (not the connection pool) be
const client = await getClient(); // Raw client, not pool
try {
await client.query("SET statement_timeout = 120000");
- // `table` is checked against pg_stat_user_tables first and quoted as an identifier.
+ // Targets come from pg_stat_user_tables and are quoted as identifiers.
const vacuum = compileQuery(
- table ? sql`VACUUM ANALYZE ${sql.id(table)}` : sql`VACUUM ANALYZE`,
+ sql`VACUUM ANALYZE ${sql.join(targets.map((r) => sql.id(r.relname)))}`,
);
await client.query(vacuum.sql);
} finally {
@@ -155,6 +156,11 @@ try {
}
```
+The backend runs as the least-privilege app role, which owns no tables. PostgreSQL skips a table
+the role may not maintain with only a warning, so the route checks the `MAINTAIN` privilege first
+and returns `403` instead of a false success. The app role holds `MAINTAIN` on public tables
+([[docs/adr/262-app-role-maintain-for-admin-vacuum|ADR-262]]).
+
**Why raw client?**
- VACUUM cannot run in a transaction block
@@ -171,8 +177,8 @@ The page consists of:
- Live row count
- Dead row count (indicator of vacuum pressure)
- Estimated size in MB
- - Last vacuum timestamp
- - Last analyze timestamp
+ - Last vacuum timestamp (the later of manual and automatic)
+ - Last analyze timestamp (the later of manual and automatic)
Timestamp and size columns keep their values on one line with reserved minimum widths. The table scrolls horizontally within its container when the window cannot fit all columns.
@@ -300,6 +306,7 @@ Full endpoint documentation: [[docs/api/admin|Admin API]].
- [[docs/features/settings|Settings & Administration]]
- [[docs/performance/index|Performance Optimization]]
- [[docs/adr/231-remove-audit-history|ADR-231: Remove audit history]] — why `db_editor_audit` was dropped
+- [[docs/adr/262-app-role-maintain-for-admin-vacuum|ADR-262: App role gets MAINTAIN so admin VACUUM runs]]
## Related Code
diff --git a/docs/guides/native-macos-runtime.md b/docs/guides/native-macos-runtime.md
index 67583f11a..6d9afb335 100644
--- a/docs/guides/native-macos-runtime.md
+++ b/docs/guides/native-macos-runtime.md
@@ -2,7 +2,7 @@
title: Native macOS Runtime Guide
type: guide
status: active
-date: 2026-10-21
+date: 2026-10-29
tags:
[guide, native-runtime, macos, electron, postgresql-18, backup, restore, demo]
description: Build, operate, diagnose, back up, and restore Vision's bundled native macOS runtime.
@@ -49,7 +49,8 @@ The generated database roles are separate:
- the cluster administrator creates databases, required extensions, and the fixed
`vision_analysis_executor` login;
- the migration owner owns application schema objects and remains `NOCREATEROLE`;
-- the application role has only runtime data privileges; and
+- the application role has only runtime data privileges plus `MAINTAIN` on public tables, so the
+ admin page can run `VACUUM ANALYZE` ([[docs/adr/262-app-role-maintain-for-admin-vacuum|ADR-262]]); and
- the Analysis executor has no role inheritance or administrative attributes and reads only the
approved Analysis views.
diff --git a/docs/reference/api-endpoint-matrix.md b/docs/reference/api-endpoint-matrix.md
index 79a1f3734..18a95f4ec 100644
--- a/docs/reference/api-endpoint-matrix.md
+++ b/docs/reference/api-endpoint-matrix.md
@@ -2,8 +2,8 @@
title: API Endpoint Matrix
type: reference
status: active
-date: 2026-10-24
-updated: 2026-10-24
+date: 2026-10-29
+updated: 2026-10-29
last_modified: 2026-10-24
adr-reference: 026
# Authoritative HTTP-operation count from openapi.yaml. The CI checker also
@@ -602,8 +602,8 @@ desktop app, which never set a token).
| POST | `/api/admin/update/apply` | Acknowledge update | — | [[docs/api/admin\|Admin]] |
| POST | `/api/admin/update/apply-and-restart` | Apply and restart | — | [[docs/api/admin\|Admin]] |
| POST | `/api/admin/investments/kinesis/sanitize-history` | Sanitize Kinesis spikes | — | [[docs/api/admin\|Admin]] |
-| GET | `/api/admin/database/stats` | Per-table live/dead row counts and size (Phase 7) | admin | [[docs/api/admin\|Admin]] |
-| POST | `/api/admin/database/vacuum` | Run VACUUM ANALYZE on one or all tables (Phase 7) | admin | [[docs/api/admin\|Admin]] |
+| GET | `/api/admin/database/stats` | Per-table live/dead row counts, size, manual and auto vacuum/analyze times (Phase 7, ADR-262) | admin | [[docs/api/admin\|Admin]] |
+| POST | `/api/admin/database/vacuum` | Run VACUUM ANALYZE on one or all tables; 403 when the role lacks MAINTAIN (Phase 7, ADR-262) | admin | [[docs/api/admin\|Admin]] |
| GET | `/api/admin/database/tables/:table/schema` | Table column schema + primary key discovery; composite-PK aware (ADR-101) | adminRateLimiter | [[docs/api/admin\|Admin]] |
| GET | `/api/admin/database/tables/:table/rows` | Keyset-paginated, filtered, sorted table read without an unbounded count; runs inside READ ONLY transaction with statement timeout (ADR-101) | adminRateLimiter | [[docs/api/admin\|Admin]] |
| POST | `/api/admin/database/tables/:table/mutate` | Batch insert/update/delete with xmin optimistic concurrency, dry-run preview, change log, matview auto-refresh (ADR-101) | adminMutateLimiter | [[docs/api/admin\|Admin]] |
diff --git a/openapi.yaml b/openapi.yaml
index e21ed2c8a..8d32b3d9e 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -2086,7 +2086,9 @@ components:
table_name,
live_rows,
dead_rows,
+ last_vacuum,
last_autovacuum,
+ last_analyze,
last_autoanalyze,
size,
size_bytes,
@@ -2104,7 +2106,19 @@ components:
type: string,
description: "BIGINT estimate from pg_stat_user_tables, as decimal text",
}
+ last_vacuum:
+ {
+ type: string,
+ nullable: true,
+ description: Last manual VACUUM (pg_stat_user_tables.last_vacuum as text),
+ }
last_autovacuum: { type: string, nullable: true }
+ last_analyze:
+ {
+ type: string,
+ nullable: true,
+ description: Last manual ANALYZE (pg_stat_user_tables.last_analyze as text),
+ }
last_autoanalyze: { type: string, nullable: true }
size:
{
@@ -16668,7 +16682,7 @@ paths:
"400":
description: VALIDATION_ERROR — `table` is not a non-empty string, or names an unknown table
"403":
- description: Insufficient database privileges to VACUUM
+ description: The database role lacks MAINTAIN on a requested table; nothing is vacuumed
/api/admin/database/tables/{table}/schema:
parameters:
diff --git a/packages/types/src/contracts/admin.ts b/packages/types/src/contracts/admin.ts
index 1a9cadc77..e30879750 100644
--- a/packages/types/src/contracts/admin.ts
+++ b/packages/types/src/contracts/admin.ts
@@ -8,14 +8,17 @@ const PgBigintTextSchema = z.string().regex(/^\d+$/);
/**
* One row of `GET /api/admin/database/stats` (routes/admin.ts). The counters
* and `size_bytes` are BIGINT, so they arrive as strings; the vacuum/analyze
- * times are `::text` casts and NULL for a table never vacuumed.
+ * times are `::text` casts and NULL for a table never vacuumed. `last_vacuum`
+ * and `last_analyze` are manual runs, the `auto` pair autovacuum runs.
*/
export const DbTableStatSchema = z.looseObject({
schemaname: z.string(),
table_name: z.string(),
live_rows: PgBigintTextSchema,
dead_rows: PgBigintTextSchema,
+ last_vacuum: z.string().nullable(),
last_autovacuum: z.string().nullable(),
+ last_analyze: z.string().nullable(),
last_autoanalyze: z.string().nullable(),
size: z.string(),
size_bytes: PgBigintTextSchema,