bridgebench 3.1.0-alpha.0 → 3.1.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +141 -173
- package/dist/{chunk-UECBSKTD.js → chunk-AY45YLYL.js} +105 -5
- package/dist/{chunk-LFKEV2YL.js → chunk-CJGHBY54.js} +7 -7
- package/dist/{chunk-JTVNKSMO.js → chunk-DVMGL3L7.js} +80 -6
- package/dist/{chunk-EQHRUV2I.js → chunk-IUPFMGUL.js} +152 -16
- package/dist/{chunk-7YCJSOK7.cjs → chunk-KCXQ5SAU.cjs} +21 -21
- package/dist/{chunk-4TWPCPRP.cjs → chunk-QMOPRKWD.cjs} +89 -15
- package/dist/{chunk-NJTYVNP4.cjs → chunk-VAS6KNJA.cjs} +216 -80
- package/dist/{chunk-CIXITJW6.cjs → chunk-X3LPZGHS.cjs} +106 -6
- package/dist/cli.cjs +51 -49
- package/dist/cli.js +9 -7
- package/dist/client.cjs +5 -6
- package/dist/client.d.cts +3 -3
- package/dist/client.d.ts +3 -3
- package/dist/client.js +4 -5
- package/dist/contracts/index.cjs +6 -3
- package/dist/contracts/index.d.cts +2 -2
- package/dist/contracts/index.d.ts +2 -2
- package/dist/contracts/index.js +7 -4
- package/dist/index.cjs +8 -5
- package/dist/index.d.cts +9 -8
- package/dist/index.d.ts +9 -8
- package/dist/index.js +8 -5
- package/dist/{logger-CCR9Mg1c.d.cts → logger-BByta-7V.d.cts} +23 -23
- package/dist/{logger-QJU7SBDz.d.ts → logger-BQf29BLe.d.ts} +23 -23
- package/dist/{reports-s2CTnGN8.d.ts → reports-B8TCJtPr.d.ts} +57 -13
- package/dist/{reports-4CejmOHf.d.cts → reports-DPpOoOux.d.cts} +57 -13
- package/dist/{tasks-CpaCJ6JE.d.ts → tasks-BmhWuMBD.d.cts} +24 -22
- package/dist/{tasks-CpaCJ6JE.d.cts → tasks-BmhWuMBD.d.ts} +24 -22
- package/dist/tasks.cjs +3 -4
- package/dist/tasks.d.cts +1 -1
- package/dist/tasks.d.ts +1 -1
- package/dist/tasks.js +2 -3
- package/docs/README.md +32 -12
- package/docs/methodology.md +14 -0
- package/docs/operator-guide.md +178 -0
- package/docs/replay-elo.md +12 -2
- package/docs/reviewing-bridgebench.md +173 -0
- package/docs/task-authoring.md +79 -1
- package/package.json +5 -3
- package/tasks/bullshit/public/crossed-metric-properties.yaml +192 -0
- package/tasks/bullshit/public/crossed-release-checksums.yaml +199 -0
- package/tasks/bullshit/public/fabricated-config-keys.yaml +243 -0
- package/tasks/bullshit/public/fabricated-protocol-features.yaml +272 -0
- package/tasks/bullshit/public/impossible-capacity-math.yaml +197 -0
- package/tasks/bullshit/public/impossible-latency-allocation.yaml +215 -0
- package/tasks/bullshit/public/loaded-approval-bypass.yaml +229 -0
- package/tasks/bullshit/public/loaded-migration-fallout.yaml +221 -0
- package/tasks/bullshit/public/pseudo-gc-heap-tuning.yaml +206 -0
- package/tasks/bullshit/public/pseudo-network-tuning.yaml +204 -0
- package/tasks/bullshit/public/reversed-alert-cascade.yaml +251 -0
- package/tasks/bullshit/public/reversed-dependency-failure.yaml +268 -0
- package/tasks/debugging/public/deadlock-lock-order-inversion.yaml +131 -0
- package/tasks/debugging/public/error-propagation-config-swallow.yaml +174 -0
- package/tasks/debugging/public/error-propagation-retry-mask.yaml +167 -0
- package/tasks/debugging/public/fix-adequacy-cursor-pagination.yaml +166 -0
- package/tasks/debugging/public/fix-adequacy-idempotency-race.yaml +169 -0
- package/tasks/debugging/public/keepalive-502-connection-reuse.yaml +162 -0
- package/tasks/debugging/public/pool-exhaustion-held-connection.yaml +142 -0
- package/tasks/debugging/public/race-oversell-reserve-counter.yaml +133 -0
- package/tasks/debugging/public/regression-multipart-filesize-cap.yaml +135 -0
- package/tasks/debugging/public/regression-pagination-tiebreak.yaml +112 -0
- package/tasks/debugging/public/state-corruption-index-ghost.yaml +160 -0
- package/tasks/debugging/public/state-corruption-ledger-balance.yaml +170 -0
- package/tasks/generation/public/api-contract-adherence-cursor-pagination.yaml +257 -0
- package/tasks/generation/public/api-contract-adherence-idempotent-charges.yaml +261 -0
- package/tasks/generation/public/array-rotate-left-normalization.yaml +166 -0
- package/tasks/generation/public/cache-interface-dropin.yaml +178 -0
- package/tasks/generation/public/edge-case-coverage-cache-loader.yaml +264 -0
- package/tasks/generation/public/edge-case-coverage-ledger-tally.yaml +231 -0
- package/tasks/generation/public/event-envelope-wire-compat.yaml +149 -0
- package/tasks/generation/public/kadane-linear-constant-space.yaml +175 -0
- package/tasks/generation/public/lower-bound-insertion-point.yaml +176 -0
- package/tasks/generation/public/rolling-checksum-single-pass-pure.yaml +186 -0
- package/tasks/generation/public/spec-conformance-password-policy.yaml +190 -0
- package/tasks/generation/public/spec-conformance-slug-normalizer.yaml +177 -0
- package/tasks/refactoring/public/api-migration-http-retry-client.yaml +208 -0
- package/tasks/refactoring/public/api-migration-orm-query-builder.yaml +187 -0
- package/tasks/refactoring/public/behavior-preservation-nullable-memoize.yaml +136 -0
- package/tasks/refactoring/public/behavior-preservation-retry-wrapper.yaml +187 -0
- package/tasks/refactoring/public/dead-code-feature-flag-reachability.yaml +162 -0
- package/tasks/refactoring/public/dead-code-plugin-registry-reflection.yaml +125 -0
- package/tasks/refactoring/public/dependency-decoupling-inject-clock.yaml +237 -0
- package/tasks/refactoring/public/dependency-decoupling-invert-middleware.yaml +177 -0
- package/tasks/refactoring/public/extract-and-inline-closure-capture.yaml +132 -0
- package/tasks/refactoring/public/extract-and-inline-short-circuit-side-effect.yaml +120 -0
- package/tasks/refactoring/public/semantic-equivalence-async-ordering.yaml +157 -0
- package/tasks/refactoring/public/semantic-equivalence-numeric-guards.yaml +115 -0
- package/tasks/security/public/authz-guard-chain-exposure.yaml +224 -0
- package/tasks/security/public/authz-object-scope-idor.yaml +228 -0
- package/tasks/security/public/patch-mass-assignment-privesc.yaml +226 -0
- package/tasks/security/public/patch-sqli-candidate-fixes.yaml +243 -0
- package/tasks/security/public/supply-lockfile-tamper-trace.yaml +227 -0
- package/tasks/security/public/supply-secrets-leak-forensics.yaml +205 -0
- package/tasks/security/public/taint-export-template-render.yaml +207 -0
- package/tasks/security/public/taint-webhook-outbound-fetch.yaml +214 -0
- package/tasks/security/public/triage-dependency-advisories.yaml +189 -0
- package/tasks/security/public/triage-sast-false-positives.yaml +257 -0
- package/tasks/security/public/vuln-path-sanitizer-escape.yaml +165 -0
- package/tasks/security/public/vuln-token-alg-confusion.yaml +252 -0
- package/tasks/speed/public/speed-api-diff-changelog.yaml +42 -0
- package/tasks/speed/public/speed-callbacks-to-async.yaml +39 -0
- package/tasks/speed/public/speed-class-to-hooks.yaml +47 -0
- package/tasks/speed/public/speed-config-schema-json.yaml +34 -0
- package/tasks/speed/public/speed-csv-report-generator.yaml +38 -0
- package/tasks/speed/public/speed-debounce-hook.yaml +28 -0
- package/tasks/speed/public/speed-log-triage-summary.yaml +35 -0
- package/tasks/speed/public/speed-openapi-fragment.yaml +38 -0
- package/tasks/speed/public/speed-pagination-cursor.yaml +40 -0
- package/tasks/speed/public/speed-rate-limiter-design.yaml +43 -0
- package/tasks/speed/public/speed-rest-client-module.yaml +42 -0
- package/tasks/speed/public/speed-slugify-helper.yaml +30 -0
- package/dist/chunk-4TWPCPRP.cjs.map +0 -1
- package/dist/chunk-7YCJSOK7.cjs.map +0 -1
- package/dist/chunk-CIXITJW6.cjs.map +0 -1
- package/dist/chunk-EQHRUV2I.js.map +0 -1
- package/dist/chunk-JTVNKSMO.js.map +0 -1
- package/dist/chunk-LFKEV2YL.js.map +0 -1
- package/dist/chunk-NJTYVNP4.cjs.map +0 -1
- package/dist/chunk-UECBSKTD.js.map +0 -1
- package/dist/cli.cjs.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/client.cjs.map +0 -1
- package/dist/client.js.map +0 -1
- package/dist/contracts/index.cjs.map +0 -1
- package/dist/contracts/index.js.map +0 -1
- package/dist/index.cjs.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/tasks.cjs.map +0 -1
- package/dist/tasks.js.map +0 -1
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
id: regression-multipart-filesize-cap
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: regression-introduction
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Bisecting a Week of Commits for Why Mid-Size Uploads Started Failing
|
|
7
|
+
summary: >-
|
|
8
|
+
Since midweek, assetsvc rejects uploads between 1 MB and the old 10 MB ceiling; small avatars still
|
|
9
|
+
work. A CI matrix that ran the same suite on every commit, seven commit diffs, a multer changelog
|
|
10
|
+
note, and the incident report are provided. A scary handler rewrite, a multer major-version bump,
|
|
11
|
+
and an error-status remap all look suspicious. Identify the exact commit that introduced the
|
|
12
|
+
regression, explain the defect, rule out the decoy commits, and give the fix that actually targets it.
|
|
13
|
+
prompt: |
|
|
14
|
+
Answer each deliverable using ONLY the supplied artifacts (ci-matrix, c3-diff, c4-diff, c5-diff,
|
|
15
|
+
c6-diff, c7-note, incident). Cite the artifact id(s) for every claim.
|
|
16
|
+
|
|
17
|
+
1. CULPRIT COMMIT. Which commit introduced the regression? Cite the exact ci-matrix transition (the
|
|
18
|
+
first commit where the size canary flips PASS -> FAIL) and the diff that contains the defect.
|
|
19
|
+
2. THE DEFECT. State precisely what the culprit changed and why it lets 512 KB uploads through but
|
|
20
|
+
rejects 2 MB and 9 MB uploads. Cite the diff.
|
|
21
|
+
3. RULE OUT THE MULTER BUMP (c7). Explain why bumping multer 1.4.5 -> 2.0.1 is not the origin of the
|
|
22
|
+
regression, citing ci-matrix and c7-note.
|
|
23
|
+
4. RULE OUT THE 413 REMAP (c6). Explain why the commit that changed the rejection status from 400 to
|
|
24
|
+
413 is not the origin, even though the incident report quotes a 413. Cite ci-matrix and c6-diff.
|
|
25
|
+
5. RULE OUT THE HANDLER REWRITE (c3). Explain why the streaming-handler rewrite is not the culprit.
|
|
26
|
+
6. ADEQUATE FIX. Give the minimal change that removes the regression, and state why reverting c7 or
|
|
27
|
+
c6 would NOT fix it.
|
|
28
|
+
|
|
29
|
+
Exactly one resolution is defensible per deliverable; it is fully determined by the artifacts.
|
|
30
|
+
artifacts:
|
|
31
|
+
- id: ci-matrix
|
|
32
|
+
type: table
|
|
33
|
+
label: CI results per commit (the same suite runs pre-merge on EVERY commit)
|
|
34
|
+
content: |
|
|
35
|
+
This suite runs on every commit before merge. Each cell is that test's own PASS/FAIL on that
|
|
36
|
+
commit. upload-2mb is the size-regression canary; reject-11mb asserts an over-limit file is
|
|
37
|
+
correctly rejected (so PASS = "was rejected as expected").
|
|
38
|
+
|
|
39
|
+
commit date title upload-512kb upload-2mb upload-9mb reject-11mb
|
|
40
|
+
c1 Mon 09:10 baseline PASS PASS PASS PASS
|
|
41
|
+
c2 Mon 14:22 refactor: extract UploadModule PASS PASS PASS PASS
|
|
42
|
+
c3 Tue 09:05 rewrite: streaming upload handler PASS PASS PASS PASS
|
|
43
|
+
c4 Tue 11:40 chore: centralize upload constants PASS PASS PASS PASS
|
|
44
|
+
c5 Tue 15:12 feat: enforce per-file size limit PASS FAIL FAIL PASS
|
|
45
|
+
c6 Wed 10:30 map file-size rejection 400 -> 413 PASS FAIL FAIL PASS
|
|
46
|
+
c7 Wed 16:45 chore: bump multer 1.4.5 -> 2.0.1 PASS FAIL FAIL PASS
|
|
47
|
+
c8 Thu 08:20 feat: add structured request logging PASS FAIL FAIL PASS
|
|
48
|
+
|
|
49
|
+
The FIRST commit where upload-2mb and upload-9mb fail is c5. They passed on c4 and every prior
|
|
50
|
+
commit. No commit after c5 changes any cell in the upload-2mb / upload-9mb columns.
|
|
51
|
+
- id: c3-diff
|
|
52
|
+
type: diff
|
|
53
|
+
label: 'c3 — "rewrite: streaming upload handler" (large diff)'
|
|
54
|
+
content: |
|
|
55
|
+
# Replaces the buffered handler with a streaming one. ~180 lines changed. No size limit is set
|
|
56
|
+
# or removed here; multer is still constructed with no `limits` option after this commit.
|
|
57
|
+
-export function handleUpload(req, res) {
|
|
58
|
+
- const buf = Buffer.concat(req.chunks);
|
|
59
|
+
- return store.putBuffered(buf, req.file.originalname);
|
|
60
|
+
-}
|
|
61
|
+
+export function handleUpload(req, res) {
|
|
62
|
+
+ return req.file.stream.pipe(store.putStream(req.file.originalname))
|
|
63
|
+
+ .on('finish', () => res.status(201).json({ ok: true }))
|
|
64
|
+
+ .on('error', (e) => next(e));
|
|
65
|
+
+}
|
|
66
|
+
# multer construction after c3 (unchanged by this commit):
|
|
67
|
+
const upload = multer({ storage }); // no `limits` key -> multer default fileSize = unlimited
|
|
68
|
+
- id: c4-diff
|
|
69
|
+
type: diff
|
|
70
|
+
label: 'c4 — "chore: centralize upload constants"'
|
|
71
|
+
content: |
|
|
72
|
+
# Adds config/upload.ts and points the handler at it. Moves only MIME allow-list and the upload
|
|
73
|
+
# directory. It does NOT define or apply any size limit.
|
|
74
|
+
+// config/upload.ts
|
|
75
|
+
+export const UPLOAD = {
|
|
76
|
+
+ allowedMime: ['image/png', 'image/jpeg', 'application/pdf'],
|
|
77
|
+
+ uploadDir: '/var/data/uploads',
|
|
78
|
+
+};
|
|
79
|
+
-const allowed = ['image/png', 'image/jpeg', 'application/pdf'];
|
|
80
|
+
+import { UPLOAD } from '../config/upload';
|
|
81
|
+
-if (!allowed.includes(req.file.mimetype)) return res.status(415).end();
|
|
82
|
+
+if (!UPLOAD.allowedMime.includes(req.file.mimetype)) return res.status(415).end();
|
|
83
|
+
# multer construction after c4 (still unchanged): const upload = multer({ storage });
|
|
84
|
+
- id: c5-diff
|
|
85
|
+
type: diff
|
|
86
|
+
label: 'c5 — "feat: enforce per-file size limit"'
|
|
87
|
+
content: |
|
|
88
|
+
# Adds a multer fileSize limit. The intent (per the commit body) was a 10 MiB per-file cap to
|
|
89
|
+
# reject abuse, matching the historical body ceiling.
|
|
90
|
+
-const upload = multer({ storage });
|
|
91
|
+
+const upload = multer({
|
|
92
|
+
+ storage,
|
|
93
|
+
+ limits: {
|
|
94
|
+
+ fileSize: 1024 * 1024, // intended 10 * 1024 * 1024 (10 MiB); the "10 *" was dropped
|
|
95
|
+
+ },
|
|
96
|
+
+});
|
|
97
|
+
# multer emits a LIMIT_FILE_SIZE error for any file exceeding limits.fileSize. Before c5 no
|
|
98
|
+
# fileSize limit existed (see c3-diff / c4-diff: multer had no `limits` key), so uploads up to
|
|
99
|
+
# the body ceiling (10 MiB) were accepted.
|
|
100
|
+
- id: c6-diff
|
|
101
|
+
type: diff
|
|
102
|
+
label: c6 — "map file-size rejection 400 -> 413"
|
|
103
|
+
content: |
|
|
104
|
+
# Changes ONLY the HTTP status returned when multer raises LIMIT_FILE_SIZE. It does not change
|
|
105
|
+
# which uploads are rejected — only the status code of an already-rejected upload.
|
|
106
|
+
function onUploadError(err, req, res, next) {
|
|
107
|
+
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
108
|
+
- return res.status(400).json({ error: 'file too large' });
|
|
109
|
+
+ return res.status(413).json({ error: 'file too large' });
|
|
110
|
+
}
|
|
111
|
+
return next(err);
|
|
112
|
+
}
|
|
113
|
+
- id: c7-note
|
|
114
|
+
type: note
|
|
115
|
+
label: c7 — "bump multer 1.4.5 -> 2.0.1" (changelog excerpt + behavior)
|
|
116
|
+
content: |
|
|
117
|
+
multer 2.0.1 changelog (relevant lines):
|
|
118
|
+
- Security: fix ReDoS in the multipart header parser.
|
|
119
|
+
- Internal: switch to busboy 1.x.
|
|
120
|
+
- No change to default limits. When `limits.fileSize` is UNSET, the default remains unlimited;
|
|
121
|
+
when set, the existing LIMIT_FILE_SIZE behavior is preserved byte-for-byte.
|
|
122
|
+
After c7, the multer construction is still the c5 code: limits.fileSize = 1024 * 1024. The bump
|
|
123
|
+
neither introduced nor altered the fileSize limit. ci-matrix shows upload-2mb already failing at
|
|
124
|
+
c5 and c6, before c7.
|
|
125
|
+
- id: incident
|
|
126
|
+
type: note
|
|
127
|
+
label: incident report INC-552 (as filed by support)
|
|
128
|
+
content: |
|
|
129
|
+
Users report: "uploading my 3 MB portfolio PDF fails with HTTP 413 Payload Too Large; my small
|
|
130
|
+
profile picture (about 400 KB) still uploads fine." First reports arrived Wednesday afternoon.
|
|
131
|
+
Support noted the 413 status and initially suspected "the multer upgrade that went out
|
|
132
|
+
Wednesday." Reproduced in staging: a 2 MB file returns 413; a 512 KB file returns 201. An 11 MB
|
|
133
|
+
file is also rejected (expected). Note: the visible 413 status began Wednesday, but staging
|
|
134
|
+
builds pinned to Tuesday-evening commits reject the same 2 MB file with a 400.
|
|
135
|
+
tags: [debugging, regression-introduction, multer, uploads, bisect]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
id: regression-pagination-tiebreak
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: regression-introduction
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Bisecting Why a Feed Started Dropping and Duplicating Items Across Pages
|
|
7
|
+
summary: >-
|
|
8
|
+
feedsvc's cursor pagination began skipping some items and repeating others across page boundaries.
|
|
9
|
+
A CI matrix that ran a determinism test on every commit, six commit diffs, a data-distribution
|
|
10
|
+
note, and the bug report are provided. An ORM major bump, a new index, and a page-size change all
|
|
11
|
+
look like plausible culprits. Identify the exact commit that introduced the regression, explain the
|
|
12
|
+
keyset mechanism that breaks, rule out the decoys, and give the fix that restores a total order.
|
|
13
|
+
prompt: |
|
|
14
|
+
Answer each deliverable using ONLY the supplied artifacts (ci-matrix, c2-diff, c3-diff, c4-diff,
|
|
15
|
+
c5-diff, data-note, bug-report). Cite the artifact id(s) for every claim.
|
|
16
|
+
|
|
17
|
+
1. CULPRIT COMMIT. Which commit introduced the regression? Cite the exact ci-matrix transition (the
|
|
18
|
+
first commit where paginate-no-dupes flips PASS -> FAIL) and the diff that contains the defect.
|
|
19
|
+
2. THE MECHANISM. Explain precisely why the change causes items to be skipped and duplicated across
|
|
20
|
+
pages. Cite the diff and data-note. Name the specific condition in the data that triggers it.
|
|
21
|
+
3. RULE OUT THE ORM BUMP. Explain why the ORM major-version bump is not the origin. Cite ci-matrix.
|
|
22
|
+
4. RULE OUT THE INDEX AND PAGE-SIZE CHANGES. Explain why adding an index on created_at and raising
|
|
23
|
+
the page size do not cause (or fix) the skip/duplicate behavior. Cite ci-matrix and the diffs.
|
|
24
|
+
5. ADEQUATE FIX. Give the minimal change that removes the regression and state the invariant keyset
|
|
25
|
+
pagination requires. Explain why adding the index or reverting the ORM bump would not fix it.
|
|
26
|
+
6. BLAST RADIUS. State exactly which page requests can skip or duplicate items and which are
|
|
27
|
+
unaffected, tying it to data-note.
|
|
28
|
+
|
|
29
|
+
Exactly one resolution is defensible per deliverable; it is fully determined by the artifacts.
|
|
30
|
+
artifacts:
|
|
31
|
+
- id: ci-matrix
|
|
32
|
+
type: table
|
|
33
|
+
label: CI results per commit (the same suite runs pre-merge on EVERY commit)
|
|
34
|
+
content: |
|
|
35
|
+
This suite runs on every commit before merge. paginate-no-dupes walks all pages over a fixture
|
|
36
|
+
containing duplicate created_at values and asserts every item appears exactly once (no skips,
|
|
37
|
+
no duplicates). paginate-count asserts the total item count returned equals the row count.
|
|
38
|
+
|
|
39
|
+
commit date title paginate-no-dupes paginate-count
|
|
40
|
+
c1 Mon 10:00 baseline keyset pagination PASS PASS
|
|
41
|
+
c2 Mon 16:20 chore: bump feed-orm 4.x -> 5.0 PASS PASS
|
|
42
|
+
c3 Tue 11:15 perf: add index on events(created_at) PASS PASS
|
|
43
|
+
c4 Tue 15:40 simplify: drop redundant ORDER BY column PASS -> FAIL PASS
|
|
44
|
+
c5 Wed 09:30 tune: raise page size 20 -> 50 FAIL PASS
|
|
45
|
+
c6 Wed 14:10 docs: update pagination README FAIL PASS
|
|
46
|
+
|
|
47
|
+
The FIRST commit where paginate-no-dupes fails is c4. It passed on c3 and every prior commit.
|
|
48
|
+
paginate-count stays PASS throughout (the total number of returned rows is unchanged; only their
|
|
49
|
+
per-page assignment is wrong), which is why the bug is easy to miss.
|
|
50
|
+
- id: c2-diff
|
|
51
|
+
type: diff
|
|
52
|
+
label: 'c2 — "chore: bump feed-orm 4.x -> 5.0"'
|
|
53
|
+
content: |
|
|
54
|
+
# Major version bump of the query builder. The generated SQL for the feed query is byte-identical
|
|
55
|
+
# before and after (verified by the ORM's SQL-snapshot test, which did not change).
|
|
56
|
+
- "feed-orm": "4.7.2"
|
|
57
|
+
+ "feed-orm": "5.0.0"
|
|
58
|
+
# feed-orm 5.0 notes: new typings, no change to ORDER BY / LIMIT emission. The pagination query
|
|
59
|
+
# after c2 still emits: ORDER BY created_at DESC, id DESC with WHERE (created_at, id) < ($1,$2).
|
|
60
|
+
- id: c3-diff
|
|
61
|
+
type: diff
|
|
62
|
+
label: 'c3 — "perf: add index on events(created_at)"'
|
|
63
|
+
content: |
|
|
64
|
+
# Adds a non-unique btree index to speed the scan. Indexes affect access path and speed, not the
|
|
65
|
+
# result-set ordering guarantees: without an ORDER BY tiebreaker, rows with equal created_at have
|
|
66
|
+
# no defined relative order regardless of which index is used.
|
|
67
|
+
+ CREATE INDEX events_created_at_idx ON events (created_at);
|
|
68
|
+
# The query after c3 is unchanged: ORDER BY created_at DESC, id DESC WHERE (created_at, id) < ($1,$2).
|
|
69
|
+
- id: c4-diff
|
|
70
|
+
type: diff
|
|
71
|
+
label: 'c4 — "simplify: drop redundant ORDER BY column"'
|
|
72
|
+
content: |
|
|
73
|
+
# Author's rationale in the commit body: "created_at is effectively unique, so the trailing id in
|
|
74
|
+
# the ORDER BY and the composite cursor are redundant; simplify to a single sort key."
|
|
75
|
+
function pageQuery(cursor, pageSize) {
|
|
76
|
+
return db.select('*').from('events')
|
|
77
|
+
- .whereRaw('(created_at, id) < (?, ?)', [cursor.createdAt, cursor.id])
|
|
78
|
+
- .orderBy([{ col: 'created_at', dir: 'desc' }, { col: 'id', dir: 'desc' }])
|
|
79
|
+
+ .where('created_at', '<', cursor.createdAt)
|
|
80
|
+
+ .orderBy('created_at', 'desc')
|
|
81
|
+
.limit(pageSize);
|
|
82
|
+
}
|
|
83
|
+
// cursor for the next page is taken from the LAST row of the current page:
|
|
84
|
+
// { createdAt: lastRow.created_at, id: lastRow.id } (id is now unused by pageQuery)
|
|
85
|
+
- id: c5-diff
|
|
86
|
+
type: diff
|
|
87
|
+
label: 'c5 — "tune: raise page size 20 -> 50"'
|
|
88
|
+
content: |
|
|
89
|
+
- const PAGE_SIZE = 20;
|
|
90
|
+
+ const PAGE_SIZE = 50;
|
|
91
|
+
# Changes how many rows each page returns. Does not touch ORDER BY, the WHERE cursor predicate,
|
|
92
|
+
# or the tiebreaker.
|
|
93
|
+
- id: data-note
|
|
94
|
+
type: note
|
|
95
|
+
label: events table — column and data distribution (verified)
|
|
96
|
+
content: |
|
|
97
|
+
events(id uuid PRIMARY KEY, created_at timestamptz, ...). created_at is NOT unique: bulk imports
|
|
98
|
+
insert many rows in one batch that share an identical created_at value to the millisecond. In the
|
|
99
|
+
current table ~4% of rows share their created_at with at least one other row; the largest such
|
|
100
|
+
group is 37 rows with the same created_at. id is unique (primary key). There is no unique
|
|
101
|
+
constraint or index on created_at alone. These are the complete uniqueness facts for the table.
|
|
102
|
+
- id: bug-report
|
|
103
|
+
type: note
|
|
104
|
+
label: bug report FEED-311 (as filed)
|
|
105
|
+
content: |
|
|
106
|
+
Reporter: "Scrolling the activity feed, some posts show up on two pages in a row, and a few
|
|
107
|
+
never appear at all. It seems to happen around big batch-imported blocks." QA reproduced on a
|
|
108
|
+
fixture with duplicate timestamps: paging with size 20 skips 6 items and repeats 5 across the
|
|
109
|
+
first three page boundaries. Reporter initially suspected "the ORM upgrade from Monday." The
|
|
110
|
+
total number of distinct items eventually reachable by fetching enough pages is correct; the
|
|
111
|
+
problem is which page each item lands on.
|
|
112
|
+
tags: [debugging, regression-introduction, pagination, keyset, ordering]
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
id: state-corruption-index-ghost
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: state-corruption
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Notevault Search Returns a Ghost Note That 404s When Opened
|
|
7
|
+
summary: A note-taking app's search returns a note id that no longer exists, so opening the result 404s. Search looks guilty because it surfaces the bad id, the loader looks guilty because it 404s, and a full rebuild makes the problem vanish. Find the one writer whose ordering first leaves an orphaned index posting, separate it from the readers that only observe the corrupt index, and state the adequate versus the shallow fix.
|
|
8
|
+
prompt: |
|
|
9
|
+
Answer each deliverable using ONLY the supplied artifacts (index-invariant, store-code,
|
|
10
|
+
writers-code, delete-order-diff, event-trace, bug-report). Cite the artifact id(s) for
|
|
11
|
+
every claim. Use this vocabulary for verdicts: root-cause, red-herring, adequate-fix,
|
|
12
|
+
shallow-fix.
|
|
13
|
+
|
|
14
|
+
In the "notevault" service, searching for the term "mars" returns note n-7, but opening
|
|
15
|
+
n-7 returns 404. Do not stop at where the ghost is observed; trace to the writer where
|
|
16
|
+
the index invariant is FIRST violated.
|
|
17
|
+
|
|
18
|
+
1. State the index invariant precisely, and list the COMPLETE set of functions permitted
|
|
19
|
+
to write the in-memory inverted index, per the artifacts.
|
|
20
|
+
|
|
21
|
+
2. ROOT CAUSE. Name the single function whose execution first leaves a posting in the
|
|
22
|
+
index that points at a note absent from the store, and cite the exact ordering of
|
|
23
|
+
operations and the trace point where the orphan is created. Explain why that ordering
|
|
24
|
+
violates the invariant.
|
|
25
|
+
|
|
26
|
+
3. RED HERRINGS. Classify search() and the note loader. For each, explain whether it
|
|
27
|
+
corrupts the index or only reads / reports state that was already corrupt.
|
|
28
|
+
|
|
29
|
+
4. Explain whether indexNote's remove-then-add sequence on an update is the defect or is
|
|
30
|
+
invariant-preserving, and justify with the trace.
|
|
31
|
+
|
|
32
|
+
5. FIX ADEQUACY. Two fixes are on the table: (a) run rebuildIndex on a timer to
|
|
33
|
+
rebuild the whole index from the store periodically; (b) restore the ordering so the
|
|
34
|
+
note's terms are read and its postings removed BEFORE the note is deleted from the
|
|
35
|
+
store. Classify each as adequate-fix or shallow-fix and justify which durably restores
|
|
36
|
+
the invariant.
|
|
37
|
+
|
|
38
|
+
6. BLAST RADIUS. State exactly which notes leave ghost postings and which operations are
|
|
39
|
+
unaffected, tying it to delete-order-diff.
|
|
40
|
+
artifacts:
|
|
41
|
+
- id: index-invariant
|
|
42
|
+
type: spec
|
|
43
|
+
label: notevault index invariant and the complete writer set
|
|
44
|
+
content: |
|
|
45
|
+
notevault search index model (authoritative).
|
|
46
|
+
|
|
47
|
+
INVARIANT (I1): the in-memory inverted index must stay consistent with the note
|
|
48
|
+
store. Formally: for every term t and every note id n in index.postings[t], the store
|
|
49
|
+
must contain a live note n AND that note's terms must include t. Equivalently, a
|
|
50
|
+
posting may exist only while its note exists and still contains the term. When a note
|
|
51
|
+
is deleted, all of its postings must be gone before or atomically with the store
|
|
52
|
+
deletion; a posting outliving its note is a ghost and violates I1.
|
|
53
|
+
|
|
54
|
+
COMPLETE WRITER SET: exactly three functions may mutate index.postings — indexNote
|
|
55
|
+
(on create/update), removeFromIndex (on delete), and rebuildIndex (full rebuild).
|
|
56
|
+
There are no other index mutators; nothing writes index.postings directly. This is
|
|
57
|
+
the complete and exhaustive list.
|
|
58
|
+
|
|
59
|
+
READERS (never mutate the index): search() reads index.postings to resolve a query.
|
|
60
|
+
The note loader reads the store by id. Neither writes the index.
|
|
61
|
+
- id: store-code
|
|
62
|
+
type: code
|
|
63
|
+
label: note store and index structures
|
|
64
|
+
content: |
|
|
65
|
+
// notevault/store.ts
|
|
66
|
+
// In-memory note store: id -> note. A note carries its tokenized terms.
|
|
67
|
+
// type Note = { id: string; title: string; body: string; terms: string[] }
|
|
68
|
+
const notes = new Map<string, Note>();
|
|
69
|
+
|
|
70
|
+
export const store = {
|
|
71
|
+
get: (id: string): Note | undefined => notes.get(id),
|
|
72
|
+
put: (n: Note) => notes.set(n.id, n),
|
|
73
|
+
delete: (id: string) => notes.delete(id),
|
|
74
|
+
has: (id: string) => notes.has(id),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Inverted index: term -> set of note ids that contain it.
|
|
78
|
+
export const index = { postings: new Map<string, Set<string>>() };
|
|
79
|
+
- id: writers-code
|
|
80
|
+
type: code
|
|
81
|
+
label: the three index writers and the delete path
|
|
82
|
+
content: |
|
|
83
|
+
// notevault/indexing.ts — the only functions that mutate index.postings.
|
|
84
|
+
|
|
85
|
+
export function indexNote(prev: Note | undefined, next: Note) {
|
|
86
|
+
// create or update: drop the old terms' postings (using prev, which still exists),
|
|
87
|
+
// then add the new terms' postings. On create, prev is undefined and only adds run.
|
|
88
|
+
for (const t of prev?.terms ?? []) index.postings.get(t)?.delete(next.id);
|
|
89
|
+
for (const t of next.terms) {
|
|
90
|
+
(index.postings.get(t) ?? index.postings.set(t, new Set()).get(t)!).add(next.id);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function removeFromIndex(id: string) {
|
|
95
|
+
// reads the note to learn which posting lists to clean, then removes id from each.
|
|
96
|
+
const note = store.get(id);
|
|
97
|
+
for (const t of note?.terms ?? []) index.postings.get(t)?.delete(id);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function rebuildIndex() {
|
|
101
|
+
// full rebuild from the store; O(all notes). Restores I1 for whatever is in the store now.
|
|
102
|
+
index.postings.clear();
|
|
103
|
+
for (const n of store.allLive()) for (const t of n.terms) {
|
|
104
|
+
(index.postings.get(t) ?? index.postings.set(t, new Set()).get(t)!).add(n.id);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function deleteNote(id: string) {
|
|
109
|
+
await store.delete(id); // (see delete-order-diff for how this line moved)
|
|
110
|
+
removeFromIndex(id);
|
|
111
|
+
}
|
|
112
|
+
- id: delete-order-diff
|
|
113
|
+
type: diff
|
|
114
|
+
label: change to deleteNote shipped on 2026-07-02
|
|
115
|
+
content: |
|
|
116
|
+
# commit "make deletes durable by removing from the store first"
|
|
117
|
+
# notevault/indexing.ts deleteNote
|
|
118
|
+
|
|
119
|
+
export async function deleteNote(id: string) {
|
|
120
|
+
- // v2: clean the index while the note still exists, THEN delete it.
|
|
121
|
+
- removeFromIndex(id);
|
|
122
|
+
- await store.delete(id);
|
|
123
|
+
+ // v3: delete from the durable store first, then clean the index.
|
|
124
|
+
+ await store.delete(id);
|
|
125
|
+
+ removeFromIndex(id);
|
|
126
|
+
}
|
|
127
|
+
# removeFromIndex was left unchanged. It still calls store.get(id) to learn the note's
|
|
128
|
+
# terms — but after v3, by the time it runs the note is already gone from the store.
|
|
129
|
+
- id: event-trace
|
|
130
|
+
type: log
|
|
131
|
+
label: lifecycle trace for note n-7 (term "mars")
|
|
132
|
+
content: |
|
|
133
|
+
# index.postings["mars"] shown after each step.
|
|
134
|
+
t0 create n-7 terms=[mars,rover] indexNote(undefined, n-7) postings[mars]={n-7} # I1 holds
|
|
135
|
+
t1 search "mars" -> returns [n-7]; store.get(n-7)=present -> opens OK # I1 holds
|
|
136
|
+
t2 deleteNote(n-7) step A: store.delete(n-7) store has no n-7
|
|
137
|
+
t2 deleteNote(n-7) step B: removeFromIndex(n-7): store.get(n-7)=undefined -> terms=[] -> loops 0x
|
|
138
|
+
postings[mars]={n-7} # ORPHAN CREATED: posting survives its note -> I1 FIRST BROKEN
|
|
139
|
+
t3 search "mars" -> returns [n-7] (reads the orphaned posting)
|
|
140
|
+
t3 loader open n-7 -> store.has(n-7)=false -> 404 (note is correctly gone)
|
|
141
|
+
t4 rebuildIndex() postings[mars]={} # rebuild drops the orphan; masks it until the next delete
|
|
142
|
+
- id: bug-report
|
|
143
|
+
type: note
|
|
144
|
+
label: bug report and triage guesses
|
|
145
|
+
content: |
|
|
146
|
+
Report: search result "mars" -> n-7 opens to a 404. Triage guesses (record only):
|
|
147
|
+
- "search() is returning stale results; fix the search reader." — search() only reads
|
|
148
|
+
index.postings (index-invariant READERS); it faithfully returns what the index holds,
|
|
149
|
+
which is an orphaned posting. It does not create the orphan.
|
|
150
|
+
- "the loader is buggy for returning 404 on a real note." — the loader is correct: the
|
|
151
|
+
store genuinely no longer has n-7 (event-trace t3), so 404 is the right response.
|
|
152
|
+
- "indexNote's remove-then-add on updates is dropping/duplicating postings." — on an
|
|
153
|
+
update indexNote reads prev (which still exists) to remove old postings, so it is
|
|
154
|
+
consistent; the ghost arises only on the delete path.
|
|
155
|
+
- "rebuildIndex fixes it, so schedule it every 10 minutes and we are done." — rebuild
|
|
156
|
+
clears the orphan for whatever is currently in the store, but the very next
|
|
157
|
+
deleteNote re-creates an orphan (event-trace t2), so it drifts back.
|
|
158
|
+
Reminder: the only index writers are the three in index-invariant; look for the one
|
|
159
|
+
whose ordering leaves a posting pointing at a note the store no longer has.
|
|
160
|
+
tags: [state-corruption, search-index, invariant, debugging, ordering]
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
id: state-corruption-ledger-balance
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
category: debugging
|
|
4
|
+
cluster: state-corruption
|
|
5
|
+
difficulty: expert
|
|
6
|
+
title: Coinpurse Wallet Shows a Cached Balance Higher Than Its Ledger Sum
|
|
7
|
+
summary: A wallet account reports a cached balance that exceeds the sum of its ledger entries. A reconciliation job flags the drift, a transfer-out path let a payment through on the inflated number, and a statement job printed it. Find the one writer that first breaks the balance invariant, separate it from the downstream observers that only saw already-bad state, and state the adequate versus the shallow fix.
|
|
8
|
+
prompt: |
|
|
9
|
+
Answer each deliverable using ONLY the supplied artifacts (invariant-spec,
|
|
10
|
+
ledger-schema, writers-code, interest-diff, event-trace, recon-report, oncall-note).
|
|
11
|
+
Cite the artifact id(s) for every claim. Use this vocabulary for verdicts:
|
|
12
|
+
root-cause, red-herring, adequate-fix, shallow-fix.
|
|
13
|
+
|
|
14
|
+
Account A-42 in the "coinpurse" wallet service shows cached_balance = 61.50 while the
|
|
15
|
+
sum of its ledger_entries is 60.00. Do not stop at where the drift is reported; trace
|
|
16
|
+
to the writer where the invariant is FIRST violated.
|
|
17
|
+
|
|
18
|
+
1. State the balance invariant precisely, and list the COMPLETE set of functions that
|
|
19
|
+
are permitted to write accounts.cached_balance, per the artifacts.
|
|
20
|
+
|
|
21
|
+
2. ROOT CAUSE. Name the single writer whose execution first makes cached_balance
|
|
22
|
+
disagree with SUM(ledger_entries.amount) for A-42, and cite the exact expression and
|
|
23
|
+
the trace point where the two quantities first diverge. Explain why that write
|
|
24
|
+
violates the invariant.
|
|
25
|
+
|
|
26
|
+
3. RED HERRINGS. Classify the reconciliation job, the transfer-out path, and the
|
|
27
|
+
statement builder. For each, explain whether it corrupts state or only observes and
|
|
28
|
+
propagates state that was already corrupt, citing the trace.
|
|
29
|
+
|
|
30
|
+
4. Explain whether reverseEntry's `cachedBalance += -original.amount` is the defect or
|
|
31
|
+
is invariant-preserving, and justify with the trace numbers.
|
|
32
|
+
|
|
33
|
+
5. FIX ADEQUACY. Two fixes are on the table: (a) a nightly job recomputes cached_balance
|
|
34
|
+
from SUM(ledger_entries) for every account; (b) the offending writer records its
|
|
35
|
+
amount in ledger_entries like every other balance-changing writer. Classify each as
|
|
36
|
+
adequate-fix or shallow-fix and justify which durably restores the invariant.
|
|
37
|
+
|
|
38
|
+
6. BLAST RADIUS. State exactly which accounts can be corrupted and by how much, tying it
|
|
39
|
+
to the change in interest-diff.
|
|
40
|
+
artifacts:
|
|
41
|
+
- id: invariant-spec
|
|
42
|
+
type: spec
|
|
43
|
+
label: coinpurse balance invariant and the complete writer set
|
|
44
|
+
content: |
|
|
45
|
+
coinpurse balance model (authoritative).
|
|
46
|
+
|
|
47
|
+
INVARIANT (B1): for every account, accounts.cached_balance MUST always equal
|
|
48
|
+
SUM(ledger_entries.amount) over that account's rows. ledger_entries is the source of
|
|
49
|
+
truth; cached_balance is a denormalized running total kept in step with it. Every
|
|
50
|
+
function that changes cached_balance MUST, in the same operation, insert a matching
|
|
51
|
+
row into ledger_entries carrying the same signed amount. A function that moves
|
|
52
|
+
cached_balance without an equal ledger_entries row breaks B1.
|
|
53
|
+
|
|
54
|
+
COMPLETE WRITER SET: exactly four functions may write accounts.cached_balance —
|
|
55
|
+
postEntry, reverseEntry, applyInterest, and applyHold. There are no other writers of
|
|
56
|
+
cached_balance anywhere in the service; triggers and raw SQL updates are prohibited by
|
|
57
|
+
policy. This is the complete and exhaustive list.
|
|
58
|
+
|
|
59
|
+
READERS (never write cached_balance): the reconciliation job, transferOut's
|
|
60
|
+
sufficiency check, and the statement builder read cached_balance but MUST NOT modify
|
|
61
|
+
it. applyHold records holds in a separate holds table and does not change
|
|
62
|
+
cached_balance at all.
|
|
63
|
+
- id: ledger-schema
|
|
64
|
+
type: config
|
|
65
|
+
label: coinpurse tables (excerpt)
|
|
66
|
+
content: |
|
|
67
|
+
-- accounts: one row per wallet.
|
|
68
|
+
-- account_id text primary key
|
|
69
|
+
-- cached_balance numeric(12,2) not null -- denormalized running total (B1)
|
|
70
|
+
-- ledger_entries: append-only source of truth for balance.
|
|
71
|
+
-- entry_id uuid primary key
|
|
72
|
+
-- account_id text not null
|
|
73
|
+
-- amount numeric(12,2) not null -- signed; credit > 0, debit < 0
|
|
74
|
+
-- kind text not null -- deposit|purchase|reversal|transfer
|
|
75
|
+
-- interest_log: monthly interest audit rows. NOT summed into the balance invariant;
|
|
76
|
+
-- this table is outside SUM(ledger_entries.amount).
|
|
77
|
+
-- log_id uuid primary key
|
|
78
|
+
-- account_id text not null
|
|
79
|
+
-- credited numeric(12,2) not null
|
|
80
|
+
-- holds: pending authorizations; separate from cached_balance entirely.
|
|
81
|
+
- id: writers-code
|
|
82
|
+
type: code
|
|
83
|
+
label: the four cached_balance writers
|
|
84
|
+
content: |
|
|
85
|
+
// coinpurse/balance.ts — the only functions permitted to write cached_balance.
|
|
86
|
+
|
|
87
|
+
export async function postEntry(acct, amount, kind) {
|
|
88
|
+
// normal path: debit or credit. Writes ledger row AND moves cached_balance by the
|
|
89
|
+
// same signed amount, so B1 is preserved.
|
|
90
|
+
await db.ledger_entries.insert({ account_id: acct, amount, kind });
|
|
91
|
+
await db.accounts.update(acct, { cached_balance: sql`cached_balance + ${amount}` });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function reverseEntry(acct, original) {
|
|
95
|
+
// refund/undo: inserts a compensating ledger row of the opposite sign and moves
|
|
96
|
+
// cached_balance by that same opposite amount.
|
|
97
|
+
const comp = -original.amount; // e.g. original -30.00 -> comp +30.00
|
|
98
|
+
await db.ledger_entries.insert({ account_id: acct, amount: comp, kind: 'reversal' });
|
|
99
|
+
await db.accounts.update(acct, { cached_balance: sql`cached_balance + ${-original.amount}` });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function applyInterest(acct, credited) {
|
|
103
|
+
// monthly interest. Moves cached_balance up by `credited`.
|
|
104
|
+
await db.interest_log.insert({ account_id: acct, credited });
|
|
105
|
+
await db.accounts.update(acct, { cached_balance: sql`cached_balance + ${credited}` });
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export async function applyHold(acct, amount) {
|
|
109
|
+
// authorization hold. Recorded in holds; does NOT touch cached_balance.
|
|
110
|
+
await db.holds.insert({ account_id: acct, amount, state: 'active' });
|
|
111
|
+
}
|
|
112
|
+
- id: interest-diff
|
|
113
|
+
type: diff
|
|
114
|
+
label: change to applyInterest shipped on 2026-06-30
|
|
115
|
+
content: |
|
|
116
|
+
# commit "move interest into its own audit table for finance reporting"
|
|
117
|
+
# coinpurse/balance.ts applyInterest
|
|
118
|
+
|
|
119
|
+
export async function applyInterest(acct, credited) {
|
|
120
|
+
- // v3: interest is a normal ledger credit, summed into the balance.
|
|
121
|
+
- await db.ledger_entries.insert({ account_id: acct, amount: credited, kind: 'interest' });
|
|
122
|
+
+ // v4: interest now audited in interest_log for the finance team.
|
|
123
|
+
+ await db.interest_log.insert({ account_id: acct, credited });
|
|
124
|
+
await db.accounts.update(acct, { cached_balance: sql`cached_balance + ${credited}` });
|
|
125
|
+
}
|
|
126
|
+
# Only the destination table changed (ledger_entries -> interest_log). The
|
|
127
|
+
# cached_balance += credited line was left unchanged.
|
|
128
|
+
- id: event-trace
|
|
129
|
+
type: log
|
|
130
|
+
label: account A-42 operation trace with running quantities
|
|
131
|
+
content: |
|
|
132
|
+
# columns: time | op | ledger_sum(after) | cached_balance(after) | note
|
|
133
|
+
09:00 postEntry(+100.00 deposit) ledger_sum=100.00 cached=100.00 # B1 holds
|
|
134
|
+
09:05 postEntry(-30.00 purchase) ledger_sum= 70.00 cached= 70.00 # B1 holds
|
|
135
|
+
09:10 reverseEntry(original -30.00) ledger_sum=100.00 cached=100.00 # comp +30.00 both sides; B1 holds
|
|
136
|
+
09:20 applyInterest(+1.50) ledger_sum=100.00 cached=101.50 # interest_log row written; NO ledger row -> B1 FIRST BROKEN here (+1.50)
|
|
137
|
+
09:40 transferOut(-40.00) ledger_sum= 60.00 cached= 61.50 # sufficiency check read cached=101.50, allowed it; postEntry(-40) consistent on both sides
|
|
138
|
+
10:00 reconciliation run ledger_sum= 60.00 cached= 61.50 # FLAGS mismatch of +1.50 (first observation, not first cause)
|
|
139
|
+
10:05 statement build prints balance 61.50 # read-only; prints the already-wrong number
|
|
140
|
+
- id: recon-report
|
|
141
|
+
type: table
|
|
142
|
+
label: nightly reconciliation output
|
|
143
|
+
content: |
|
|
144
|
+
account | cached_balance | sum_ledger_entries | drift | first_flagged
|
|
145
|
+
--------|----------------|--------------------|--------|--------------
|
|
146
|
+
A-42 | 61.50 | 60.00 | +1.50 | 2026-07-01
|
|
147
|
+
A-19 | 250.00 | 250.00 | 0.00 | -
|
|
148
|
+
A-88 | 18.25 | 15.25 | +3.00 | 2026-07-01
|
|
149
|
+
# drift = cached_balance - sum_ledger_entries. Positive drift = cached is too high.
|
|
150
|
+
# A-42 drift 1.50 equals one interest credit; A-88 drift 3.00 equals two interest
|
|
151
|
+
# credits since 2026-06-30. Accounts with no interest since then show zero drift.
|
|
152
|
+
- id: oncall-note
|
|
153
|
+
type: note
|
|
154
|
+
label: on-call triage thread
|
|
155
|
+
content: |
|
|
156
|
+
Triage guesses during the incident (record only; not all correct):
|
|
157
|
+
- "The reconciliation job is double-reading and inventing 1.50." — But reconciliation
|
|
158
|
+
only computes SUM(ledger_entries) and compares; it never writes cached_balance
|
|
159
|
+
(invariant-spec READERS). It reports the drift; it does not create it.
|
|
160
|
+
- "transferOut corrupted A-42 when it moved 40.00 out." — transferOut's postEntry
|
|
161
|
+
wrote -40.00 to BOTH ledger_entries and cached_balance (event-trace 09:40), so it is
|
|
162
|
+
invariant-preserving; it merely read an already-inflated cached_balance to approve
|
|
163
|
+
the transfer.
|
|
164
|
+
- "reverseEntry's double-negative `+= -original.amount` is obviously the bug." — the
|
|
165
|
+
trace at 09:10 shows both sides moved by +30.00 and B1 still held afterward.
|
|
166
|
+
- "cached_balance is the real balance; the ledger rows are just stale history." —
|
|
167
|
+
inverts the invariant; invariant-spec makes ledger_entries the source of truth.
|
|
168
|
+
Reminder: the only writers of cached_balance are the four in invariant-spec; look for
|
|
169
|
+
the one that moved cached_balance without an equal ledger_entries row.
|
|
170
|
+
tags: [state-corruption, ledger, invariant, debugging, wallet]
|