Skip to content

Commit 953641b

Browse files
committed
v0.8.0: dashboard audit overhaul — ISO timestamps, rejection_reason, audit_log
Bug 1 — ISO 8601 UTC timestamps with Z suffix: SQLite CURRENT_TIMESTAMP ("YYYY-MM-DD HH:MM:SS") is ambiguous about timezone and is parsed as local time by the browser. Switched every write path to datetime.now(timezone.utc) serialized as "YYYY-MM-DDTHH:MM:SSZ". Legacy rows are rewritten in-place on first open. Bug 2 — rejection_reason persisted: The issued_seals table had no rejection_reason column, so the dashboard REJECTION_LOG always showed an empty REASON cell. Added the column, wired client.py to pass the reason through for all three rejection paths (budget, engine, success→null), and the dashboard now selects and renders it. audit_log feature: New audit_log table (id, event_type, vendor, reasoning, timestamp). mcp_server.request_purchaser_info now calls record_audit_event on every invocation, wrapped in try/except so logging can never block the main flow. New /api/audit dashboard endpoint + AUDIT_LOG section in the UI. Schema migration (upgrade-safe, idempotent): (1) rebuild issued_seals if it still has card_number/cvv columns (very-legacy path — preserves masked data) (2) ALTER ADD rejection_reason if missing (3) UPDATE timestamp format from "YYYY-MM-DD HH:MM:SS" to "YYYY-MM-DDTHH:MM:SSZ" (4) CREATE audit_log table if missing Running migration twice is a no-op. Dashboard/tracker schema drift fix: dashboard/server.py no longer runs its own CREATE TABLE for issued_seals — it delegates to PopStateTracker so the dashboard and MCP server always agree on schema, even when the dashboard is launched first against a legacy DB. Other: - datetime.utcnow() → datetime.now(timezone.utc) (3.12+ compat) - CONTRIBUTING.md: schema-change SOP, port 3210 rationale - 14 new regression tests in tests/test_audit_and_migration.py (156 total passing)
1 parent afb4cc5 commit 953641b

File tree

10 files changed

+521
-77
lines changed

10 files changed

+521
-77
lines changed

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.0] - 2026-04-10
9+
10+
### Added
11+
- **`audit_log` table:** informational audit trail for MCP tool invocations. Every `request_purchaser_info` call now logs `event_type`, `vendor`, `reasoning`, and an ISO 8601 UTC timestamp. Non-blocking — failures to log never interrupt the main flow.
12+
- **Dashboard AUDIT_LOG section:** new table rendering `/api/audit` events (id, event_type, vendor, reasoning, timestamp).
13+
- **`PopStateTracker.record_audit_event()` / `.get_audit_events()`:** public API for emitting and reading audit events.
14+
15+
### Fixed
16+
- **Bug 1 — timestamps now ISO 8601 with `Z` suffix:** `issued_seals.timestamp` previously used SQLite `CURRENT_TIMESTAMP` (`YYYY-MM-DD HH:MM:SS`), which is ambiguous about timezone and parsed as local time by browsers. New inserts use `datetime.now(timezone.utc)` serialized as `YYYY-MM-DDTHH:MM:SSZ`. Legacy rows are migrated in-place on first open.
17+
- **Bug 2 — `rejection_reason` column now persisted:** dashboard REJECTION_LOG previously showed an empty REASON column because `issued_seals` had no `rejection_reason` column. Added column + wired `client.py` to pass the reason through for all three rejection paths (budget, engine, success→null). Migration adds the column to legacy DBs.
18+
- **Dashboard/tracker schema drift:** `dashboard/server.py` used to run its own `init_db()` which didn't know about new columns. It now delegates schema creation + migration to `PopStateTracker`, so the dashboard and MCP server always agree on schema even if the dashboard is launched first against a legacy DB.
19+
20+
### Changed
21+
- **Schema migration (upgrade-safe):** opening a legacy DB now (1) rebuilds `issued_seals` if it still has `card_number`/`cvv` columns (very-legacy path, preserves masked data); (2) adds `rejection_reason` if missing; (3) rewrites legacy `YYYY-MM-DD HH:MM:SS` timestamps to ISO 8601 Z format; (4) creates `audit_log` table. Migration is idempotent — running twice is a no-op.
22+
- **`datetime.utcnow()``datetime.now(timezone.utc)`:** the former is deprecated in Python 3.12+ and returns a naive datetime. The latter is timezone-aware and future-proof.
23+
- **Dashboard port 3210:** no functional change, but documented: port was chosen arbitrarily during initial dashboard bring-up and is kept for continuity with existing user bookmarks.
24+
825
## [0.6.34] - 2026-04-06
926

1027
### Fixed

CONTRIBUTING.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,19 @@ We use `pytest` for our test suite. To run all tests:
6868
pytest
6969
```
7070

71+
### Schema Changes
72+
73+
`PopStateTracker` (`pop_pay/core/state.py`) is the single source of truth for the SQLite schema. The dashboard (`dashboard/server.py`) delegates all table creation and migration to `PopStateTracker` on startup. If you add or modify a column:
74+
75+
1. Update the `CREATE TABLE` in `PopStateTracker.__init__` so fresh DBs get the new shape.
76+
2. Add a migration branch next to the existing ones (add-column / rebuild) so legacy DBs upgrade in place. **Migrations must be idempotent** — running them on an already-migrated DB must be a no-op.
77+
3. Use ISO 8601 UTC with a `Z` suffix for all timestamps (`datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")`). Do **not** use SQLite `CURRENT_TIMESTAMP`, which is ambiguous about timezone and parses as local time in browsers.
78+
4. Add a regression test in `tests/test_audit_and_migration.py` that constructs a pre-change DB, opens it with `PopStateTracker`, and asserts the new shape.
79+
80+
### Dashboard Port
81+
82+
The local dashboard listens on **port 3210** by default. This number was chosen arbitrarily during initial bring-up; it has no special meaning and is kept stable so existing user bookmarks continue to work. Override with the `--port` flag if you need to run multiple dashboards side-by-side.
83+
7184
---
7285

7386
## Call for Contributions
@@ -112,7 +125,7 @@ The `PopBrowserInjector` uses Playwright's `connectOverCDP` for cross-origin ifr
112125
**Implementation notes:**
113126
- `mcp_server.py` `request_virtual_card` and `request_purchaser_info` should catch all rejection/block paths and return the opaque string before returning to the agent.
114127
- `pop_state.db` audit log already stores structured data — the detailed reason should continue to be written there.
115-
- A new `rejection_detail` column (or existing `rejection_reason`) in `issued_seals` / audit log should surface in the Dashboard's transaction table.
128+
- The `rejection_reason` column on `issued_seals` (added in v0.8.0) already surfaces in the Dashboard's REJECTION_LOG table. Any new opaque-response work should continue to write the full reason there while returning the generic string to the MCP caller.
116129
- Edge case: injection failures that require agent action (e.g. "card fields not found — pass page_url") are UX errors, not security rejections, and may still return actionable messages to the agent.
117130

118131
### 7. Known Payment Processors List

dashboard/dashboard.js

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,27 @@ document.addEventListener('DOMContentLoaded', () => {
55
const utilizationFillEl = document.getElementById('utilization-fill');
66
const sealsBody = document.getElementById('seals-body');
77
const rejectedBody = document.getElementById('rejected-body');
8+
const auditBody = document.getElementById('audit-body');
89
const refreshBtn = document.getElementById('refresh-btn');
910
const maxBudgetInput = document.getElementById('max-daily-budget');
1011
const saveSettingsBtn = document.getElementById('save-settings');
1112

13+
const escapeHtml = (value) => {
14+
if (value === null || value === undefined) return '';
15+
return String(value)
16+
.replace(/&/g, '&')
17+
.replace(/</g, '&lt;')
18+
.replace(/>/g, '&gt;')
19+
.replace(/"/g, '&quot;')
20+
.replace(/'/g, '&#39;');
21+
};
22+
23+
const formatTimestamp = (ts) => {
24+
if (!ts) return '';
25+
const d = new Date(ts);
26+
return isNaN(d.getTime()) ? ts : d.toLocaleString();
27+
};
28+
1229
let sealsData = [];
1330

1431
const formatCurrency = (amount) => {
@@ -20,19 +37,22 @@ document.addEventListener('DOMContentLoaded', () => {
2037

2138
const fetchData = async () => {
2239
try {
23-
const [budgetRes, sealsRes, rejectedRes] = await Promise.all([
40+
const [budgetRes, sealsRes, rejectedRes, auditRes] = await Promise.all([
2441
fetch('/api/budget/today'),
2542
fetch('/api/seals'),
26-
fetch('/api/seals?status=rejected')
43+
fetch('/api/seals?status=rejected'),
44+
fetch('/api/audit')
2745
]);
2846

2947
const budget = await budgetRes.json();
3048
sealsData = await sealsRes.json();
3149
const rejected = await rejectedRes.json();
50+
const audit = await auditRes.json();
3251

3352
updateBudget(budget);
3453
renderSeals(sealsData);
3554
renderRejected(rejected);
55+
renderAudit(audit);
3656
} catch (error) {
3757
console.error('Failed to fetch dashboard data:', error);
3858
}
@@ -63,12 +83,12 @@ document.addEventListener('DOMContentLoaded', () => {
6383
seals.forEach(seal => {
6484
const row = document.createElement('tr');
6585
row.innerHTML = `
66-
<td>${seal.seal_id}</td>
86+
<td>${escapeHtml(seal.seal_id)}</td>
6787
<td>${formatCurrency(seal.amount)}</td>
68-
<td>${seal.vendor}</td>
69-
<td style="color: ${getStatusColor(seal.status)}">${seal.status}</td>
70-
<td>${seal.masked_card || 'N/A'}</td>
71-
<td>${new Date(seal.timestamp).toLocaleString()}</td>
88+
<td>${escapeHtml(seal.vendor)}</td>
89+
<td style="color: ${getStatusColor(seal.status)}">${escapeHtml(seal.status)}</td>
90+
<td>${escapeHtml(seal.masked_card || 'N/A')}</td>
91+
<td>${escapeHtml(formatTimestamp(seal.timestamp))}</td>
7292
`;
7393
sealsBody.appendChild(row);
7494
});
@@ -79,16 +99,32 @@ document.addEventListener('DOMContentLoaded', () => {
7999
rejected.forEach(seal => {
80100
const row = document.createElement('tr');
81101
row.innerHTML = `
82-
<td>${seal.seal_id}</td>
102+
<td>${escapeHtml(seal.seal_id)}</td>
83103
<td>${formatCurrency(seal.amount)}</td>
84-
<td>${seal.vendor}</td>
85-
<td>${seal.status}</td>
86-
<td>${new Date(seal.timestamp).toLocaleString()}</td>
104+
<td>${escapeHtml(seal.vendor)}</td>
105+
<td>${escapeHtml(seal.rejection_reason || '')}</td>
106+
<td>${escapeHtml(formatTimestamp(seal.timestamp))}</td>
87107
`;
88108
rejectedBody.appendChild(row);
89109
});
90110
};
91111

112+
const renderAudit = (events) => {
113+
if (!auditBody) return;
114+
auditBody.innerHTML = '';
115+
events.forEach(event => {
116+
const row = document.createElement('tr');
117+
row.innerHTML = `
118+
<td>${escapeHtml(event.id)}</td>
119+
<td>${escapeHtml(event.event_type)}</td>
120+
<td>${escapeHtml(event.vendor || '')}</td>
121+
<td>${escapeHtml(event.reasoning || '')}</td>
122+
<td>${escapeHtml(formatTimestamp(event.timestamp))}</td>
123+
`;
124+
auditBody.appendChild(row);
125+
});
126+
};
127+
92128
const getStatusColor = (status) => {
93129
switch (status.toLowerCase()) {
94130
case 'issued': return 'var(--accent-green)';

dashboard/index.html

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,26 @@ <h3>REJECTION_LOG</h3>
8282
</table>
8383
</div>
8484
</section>
85+
86+
<section class="audit-log">
87+
<h3>AUDIT_LOG</h3>
88+
<div class="table-container">
89+
<table id="audit-table">
90+
<thead>
91+
<tr>
92+
<th>ID</th>
93+
<th>EVENT_TYPE</th>
94+
<th>VENDOR</th>
95+
<th>REASONING</th>
96+
<th>TIMESTAMP</th>
97+
</tr>
98+
</thead>
99+
<tbody id="audit-body">
100+
<!-- Data injected here -->
101+
</tbody>
102+
</table>
103+
</div>
104+
</section>
85105
</main>
86106

87107
<script src="dashboard.js"></script>

dashboard/server.py

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ def do_GET(self):
3333
query = parse_qs(parsed_path.query)
3434
status_filter = query.get("status", [None])[0]
3535
self.get_seals(status_filter)
36+
elif path == "/api/audit":
37+
query = parse_qs(parsed_path.query)
38+
try:
39+
limit = int(query.get("limit", ["100"])[0])
40+
except ValueError:
41+
limit = 100
42+
self.get_audit(limit)
3643
elif path.startswith("/api/"):
3744
self._set_headers(404)
3845
self.wfile.write(json.dumps({"error": "Not Found"}).encode())
@@ -113,12 +120,12 @@ def get_seals(self, status_filter=None):
113120

114121
if status_filter:
115122
cursor.execute(
116-
"SELECT seal_id, amount, vendor, status, masked_card, timestamp FROM issued_seals WHERE LOWER(status) = LOWER(?) ORDER BY timestamp DESC",
123+
"SELECT seal_id, amount, vendor, status, masked_card, timestamp, rejection_reason FROM issued_seals WHERE LOWER(status) = LOWER(?) ORDER BY timestamp DESC",
117124
(status_filter,)
118125
)
119126
else:
120127
cursor.execute(
121-
"SELECT seal_id, amount, vendor, status, masked_card, timestamp FROM issued_seals ORDER BY timestamp DESC"
128+
"SELECT seal_id, amount, vendor, status, masked_card, timestamp, rejection_reason FROM issued_seals ORDER BY timestamp DESC"
122129
)
123130

124131
rows = cursor.fetchall()
@@ -148,6 +155,30 @@ def get_seals(self, status_filter=None):
148155
self._set_headers()
149156
self.wfile.write(json.dumps(seals).encode())
150157

158+
def get_audit(self, limit: int = 100):
159+
conn = self.get_db_connection()
160+
conn.row_factory = sqlite3.Row
161+
cursor = conn.cursor()
162+
# audit_log is created on first MCP server / tracker init; if the
163+
# dashboard is launched before that, the table may not yet exist.
164+
cursor.execute(
165+
"CREATE TABLE IF NOT EXISTS audit_log ("
166+
"id INTEGER PRIMARY KEY AUTOINCREMENT, "
167+
"event_type TEXT NOT NULL, "
168+
"vendor TEXT, "
169+
"reasoning TEXT, "
170+
"timestamp TEXT NOT NULL)"
171+
)
172+
cursor.execute(
173+
"SELECT id, event_type, vendor, reasoning, timestamp "
174+
"FROM audit_log ORDER BY timestamp DESC, id DESC LIMIT ?",
175+
(limit,),
176+
)
177+
rows = [dict(r) for r in cursor.fetchall()]
178+
conn.close()
179+
self._set_headers()
180+
self.wfile.write(json.dumps(rows).encode())
181+
151182
def put_setting(self, key):
152183
content_length = int(self.headers.get('Content-Length', 0))
153184
body = self.rfile.read(content_length)
@@ -172,25 +203,15 @@ def put_setting(self, key):
172203
self.wfile.write(json.dumps({"key": key, "value": value}).encode())
173204

174205
def init_db(db_path):
206+
# Delegate schema creation + migrations to the canonical tracker so
207+
# dashboard and MCP server always agree on the schema, even if the
208+
# dashboard is launched first against a legacy DB.
209+
from pop_pay.core.state import PopStateTracker
210+
tracker = PopStateTracker(db_path=db_path)
211+
tracker.close()
212+
175213
conn = sqlite3.connect(db_path)
176214
cursor = conn.cursor()
177-
cursor.execute("""
178-
CREATE TABLE IF NOT EXISTS daily_budget (
179-
date TEXT PRIMARY KEY,
180-
spent_amount FLOAT
181-
)
182-
""")
183-
cursor.execute("""
184-
CREATE TABLE IF NOT EXISTS issued_seals (
185-
seal_id TEXT PRIMARY KEY,
186-
amount FLOAT,
187-
vendor TEXT,
188-
status TEXT,
189-
masked_card TEXT,
190-
expiration_date TEXT,
191-
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
192-
)
193-
""")
194215
cursor.execute("""
195216
CREATE TABLE IF NOT EXISTS dashboard_settings (
196217
key TEXT PRIMARY KEY,

pop_pay/client.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ async def process_payment(self, intent: PaymentIntent) -> VirtualSeal:
2323
)
2424
# Record rejection
2525
self.state_tracker.record_seal(
26-
seal.seal_id,
27-
seal.authorized_amount,
28-
intent.target_vendor,
29-
status=seal.status
26+
seal.seal_id,
27+
seal.authorized_amount,
28+
intent.target_vendor,
29+
status=seal.status,
30+
rejection_reason=seal.rejection_reason,
3031
)
3132
return seal
3233

@@ -41,10 +42,11 @@ async def process_payment(self, intent: PaymentIntent) -> VirtualSeal:
4142
)
4243
# Record rejection
4344
self.state_tracker.record_seal(
44-
seal.seal_id,
45-
seal.authorized_amount,
46-
intent.target_vendor,
47-
status=seal.status
45+
seal.seal_id,
46+
seal.authorized_amount,
47+
intent.target_vendor,
48+
status=seal.status,
49+
rejection_reason=seal.rejection_reason,
4850
)
4951
return seal
5052

@@ -64,7 +66,8 @@ async def process_payment(self, intent: PaymentIntent) -> VirtualSeal:
6466
intent.target_vendor,
6567
status=record_status,
6668
masked_card=f"****-****-****-{seal.card_number[-4:]}" if seal.card_number else "****-****-****-????",
67-
expiration_date=seal.expiration_date
69+
expiration_date=seal.expiration_date,
70+
rejection_reason=seal.rejection_reason,
6871
)
6972

7073
if seal.status.lower() != "rejected":

0 commit comments

Comments
 (0)