Skip to content

ACLED pagination walks cursors, not pages — offset dies 27 Oct (#487) - #490

Merged
Polichinel merged 6 commits into
developmentfrom
feat/acled-cursor-pagination
Oct 12, 2026
Merged

Polichinel merged 6 commits into
developmentfrom
feat/acled-cursor-pagination

Conversation

@Polichinel

@Polichinel Polichinel commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Closes #487 (by hand — Closes does not fire on PRs into development).

ACLED removed offset (&page=N) pagination on 27 October 2026. This migrates
fetch_paginated() to cursors and records the parity evidence while that evidence is still
possible to collect.

Why the failure shape drove the approach

A missed migration does not crash on the 27th. It surfaces on 21 November as a short harvest —
quiet, a month late, and in the dataset rather than the logs. ADR-027 exists because that already
happened to UCDP: page_size=50000 returned 335,918 of 384,918 events and the harvest reported
success.

So this is not treated as a twelve-line diff. It is treated as a change to a termination condition
in a monthly cron that nobody watches.

Six commits, in a deliberate order

18029fe — pre-registration, before any implementation code. Six hypotheses with falsification
criteria, and a §4 decision table committing to both outcomes including the branch nobody plans
for: if the two walks disagree, stop and ship neither. Committing this first is what makes "parity
held" a result rather than a rationalisation.

13ddda1 — the implementation. Two terminators deleted, and the deletion is the point:

if not results: break
if len(results) < config.page_size: break

Under offset pagination a short page genuinely meant the end of the series. Under cursor pagination
it means nothing, and only next_cursor is authoritative. Two conditions now raise rather than
guess (ADR-011): a response missing next_cursor, and a response with no events while the cursor
is still live.

eea3068 — the parity evidence, the docs, the register.

47089fd, acba3fb, 8a0c6fd — two review rounds. /code-review medium found four real
defects; /review-diff found three more, one of them in the detection drill itself. See
"What the reviews changed" below.

What the live probe changed about the design

next_cursor is an int (observed 1156286), not a string. So 0 is both the starting cursor
and falsy, and termination must test is None — if not next_cursor would end a walk on a
legitimate cursor. That became mutant M8, which the planned six mutants would not have probed.

The measurement

7,609 events, 8 requests each way, identical on all four oracles, content digest
ae4a6e815168e95f both times. Oracles: event count, event_id_cnty set, full-record equality over
the sorted lists, and the content_digest the ingestion ledger records.

The digest is the load-bearing one because validate_events() sorts before hashing — so a
different page ordering could not produce a false mismatch — and because it is the exact value
_year_is_cached() compares against. Full-record equality runs alongside it because the digest
covers only event_id_cnty and fatalities.

The detection drill is a precondition, not a flag

main() aborts if it fails. Six drills: identical input reports parity; a dropped event trips
all four oracles; a changed field trips records_equal even where counts and id sets agree;
and the two walk implementations actually diverge on input designed to separate them.

Verified by sabotage — forcing "parity": True, and separately deleting the short-page break from
the frozen offset walk. Both caught, exit 1.

This matters because the existing 22 ACLED mocks cannot substitute: they serve an ordered
side_effect list without inspecting the request, so they cannot tell a correct walk from one that
asks for the wrong page and is handed the right answer anyway.

H4 came back negative, and it is recorded that way

No page arrived short while the cursor was live. Every non-terminal page was exactly full; the final
page was short and carried a null cursor — so the deleted break would have agreed on this
slice.

That is in findings.md as a negative result rather than omitted. The deletion is structurally
right but not vindicated by this measurement. The honest basis for believing it is mutant M1
being caught, not H4 being positive.

H5 held, which decided whether C-153 closes

count and total_count are still null on every page under cursor pagination. So ACLED still
has no server-declared total, ADR-027's len(fetched) >= TotalCount * (1 - tolerance) rule still
cannot be applied to it, and C-153 is amended rather than closed. Had either field carried a
value it would have closed outright — predicting them null and checking anyway is why that is now
known rather than assumed.

Tests

17 failed on the bare implementation. Eight were fixed by one defaulted argument —
_make_acled_response(events, next_cursor=None), so a single-response test now describes a
complete walk.

Two needed care rather than patching: test_mid_pagination_token_refresh and
test_401_mid_pagination_refreshes_token are the only coverage of the token-refresh and
401-recovery branches inside pagination, and both would have gone green as single-page walks while
testing nothing. They carry live cursors instead.

New: full-final-page termination (the old code could not have passed it), short-page-with-live-cursor
continuation, next_cursor == 0 is live, cursor threading asserted on request count (an
event-count assertion cannot catch cursor = 0), both raise paths, and empty-with-null returning
[].

tests/test_acled_cursor_parity_fixture.py replays the recorded envelopes through the real
fetch_paginated — 31 event fields, 9 envelope keys, the actual opaque cursors in the actual order
— and carries its own truncation drill, because every other assertion in it would also pass against
a loop that stopped early.

All 12 termination mutants caught, baseline green, git diff clean afterwards. Two of them
exist only because a finding suggested them rather than the plan: M8 (terminate on falsiness
rather than is None, which the int cursor made a live hazard) and M12 (drop the non-scalar
cursor guard).

Fixtures: a separate, narrow slice

--write-fixtures is off by default. The headline walk recorded 9.9 MB per side against a repo
whose largest existing fixture is 9.4 KB. Trimming events would have broken the digest oracle;
shrinking the headline slice would have weakened the evidence. So the two artifacts get two slices,
and the fixture run is Somalia / 84 events / 113 KB — still a genuine two-page walk with a full
non-terminal page and a short terminal one.

Logged as a deviation in the pre-analysis plan rather than quietly re-scoped.

These fixtures cannot be regenerated after 27 October by anyone.

Register

  • C-153 amended. It named the short-page heuristic as ACLED's only detection signal — which
    this change deletes. Reading the register before touching the code is what turned two silent
    falsehoods into two amendments. ADR-027's Implementation Notes got the same treatment.
  • C-359 added. n_events is written to the ingestion ledger and never read back, so a non-zero
    shortfall is recorded as outcome: "success". Deliberately not fixed here — it touches shared
    provenance code and would confound two changes in one release. Tracked as A short ACLED harvest is recorded as success — n_events is written to the ledger and never read #488.

Scope, stated rather than implied

This change owns one sentence: the pagination loop cannot silently truncate. The
ledger-level count check is #488.

Verification

Check Result
ruff check . clean
mypy src/ (strict) clean, 89 files
docs/validate_docs.sh passed
test_docs_citations.py passed (symbol-name citations only)
All 14 register-reading test files 41 passed, never sampled
Full suite, unpiped exit code 2 failures, both pre-existing and unrelated — test_falsification_shining_codex.py asserts a filename views-models retired in their 3f2d98ec. Skipped in CI (sibling repo absent). Tracked as #489
Detection drill 6/6; each verified by sabotage
Live parity exit 0, digests identical
Mutation drill 12/12 caught

What the reviews changed

Two rounds, seven findings, all fixed. Four are worth naming here because they change claims made
above or in findings.md.

The walk had no termination bound (/code-review, medium). Both old terminators were gone,
page was only a counter, and production passes no max_pages — only the --proof probe does. A
cursor that stopped advancing, whether echoed by a bug or reset by a restatement, would have
re-requested the same full page forever: unbounded duplicates, one request per page_delay, against
a rate-limited API, in an unattended monthly cron. Under offset pagination page += 1 guaranteed
forward progress; under cursor pagination only the server can.

The review also caught the inconsistency that makes this embarrassing rather than merely missed:
the parity script had given its own walk a 40-request cap, with a comment explaining this exact
risk.
The guard existed in the instrument and not in the thing being measured. Now a seen-cursor
set, which also catches a longer cycle (A→B→A).

The parity script could report PARITY on two truncated walks (/code-review, low). Both stopped
silently at the cap and the comparison then ran on two prefixes, which agree on all four data
oracles. On a slice larger than 40 × limit it would have logged "PARITY: both walks returned
identical data" and exited 0 — the single verdict §4 of the pre-analysis plan treats as licence to
ship. That is the error class this entire investigation exists to prevent, committed by its own
instrument.
It never touched the recorded measurements (both ran to natural completion in 8 and 2
requests), but it could have.

A non-scalar next_cursor crashed with a bare TypeError (/review-diff, warning). The
progress guard tracks cursors in a set, so a list or dict cursor hit
TypeError: unhashable type: 'list' out of the membership test — verified empirically. Every other
envelope anomaly in this loop names the field and says what to check; this one handed an unattended
cron a stack trace with nothing pointing at ACLED.

And the drill had a hole the refactor had just opened (found by sabotage, not by review).
Moving the completion check into _compare — itself the fix for a two-owners-of-the-verdict
finding — left nothing verifying that _compare honours the flag. Hard-coding
walks_ran_to_completion: True passed the entire six-part drill cleanly. Drill 6 closes it.
That is the second time in this branch that sabotaging a check caught something that would
otherwise have shipped.

One unrelated guard tripped, and the fix is precedented

test_falsification_ghsbuilts_coverage_parity F1 requires GHS-BUILT-S to hold ≥70% of its siblings'
test-function count. The 12 tests added here moved the denominator 245 → 257 and the ratio 73% →
69%. GHS-BUILT-S did not regress — it is 178 functions before and after.

F2 was lowered 70% → 65% on 2026-06-29 for exactly this mechanism ("any new assertion in the
'other' files tripped the test"). F1 now follows, with the trigger recorded in its docstring.
Adding GHS-BUILT-S tests to restore a ratio would be gaming the metric rather than improving
anything. Both F1 and F2 also carried failure messages claiming 70% while enforcing a different
number.

Worth stating without acting on it: that guard penalises adding tests anywhere in its sibling set.
It is a relative measure doing a job that wants an absolute floor. Not this branch's business to
redesign.

Not in this PR

Release and deploy. The server runs a tag, so cursor code reaches production only via
1.13.0 → 1.14.0 and a deploy. Recommended before the 21 October cron, so the October run
exercises it while offset still works at ACLED and v1.13.0 is a live revert target. Separately
gated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9

Polichinel and others added 6 commits October 10, 2026 20:53
… it (#487)

ACLED removes offset pagination on 27 October 2026. The migration is one loop,
but it deletes that loop's termination condition and replaces it with another,
and a termination bug in a harvester returns fewer events rather than failing.
Nothing reads n_events back to notice (#488), so it would become the cached
truth on the following run. ADR-027 exists because that already happened once.

So the parity claim needs a measurement, and the measurement can only be taken
before the 27th while both methods still work. This commit lands first so that
"parity held" is a result and not a rationalisation:

- six hypotheses, including two with no prior (H4 short pages mid-walk, H5
  whether the cursor envelope populates count/total_count, which would close
  C-153 outright rather than amend it)
- a decision table committing to both outcomes, including the branch nobody
  plans for: if the two walks disagree, stop and ship neither
- the detection drill as a precondition, not a flag. The existing ACLED tests
  mock an ordered list of responses without inspecting the request, so they
  cannot tell a correct walk from one handed the right answer by accident.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
…487)

Confirmed against live ACLED first: next_cursor IS a top-level body key,
and it is an INT (observed 1148657), not a string. So 0 is both the
starting cursor and a falsy value, and termination must test `is None` —
`if not next_cursor` would end a walk on a legitimate cursor.

Two terminators deleted, and the deletion is the whole point:

  if not results: break
  if len(results) < config.page_size: break

Under offset pagination a short page genuinely did mean the end of the
series. Under cursor pagination it means nothing at all, and only
next_cursor is authoritative. Keeping either break is how this migration
would have silently truncated a harvest — which nothing downstream would
notice, because n_events is written to the ledger and never read (#488).

Two new raises, both fail-loud per ADR-011, because ACLED reports
count/total_count as null (C-153) so next_cursor is the only
completeness signal the response carries:
  - envelope with no next_cursor key — unrecoverable, names the field
    and lists the keys it did get, same shape as the missing
    'access_token' guard
  - no events while the cursor is still live — refuses both to continue
    (unbounded walk against a rate-limited API) and to stop (the silent
    truncation). H6 of the pre-analysis plan predicts this never fires
    on a real walk; the live run tests that.

AcledConfig is untouched — no field added or removed. page_size is
already the `limit`, and _fetch_single_year rebuilds the per-year config
by re-listing all eleven fields, so a new one would have been silently
dropped there. max_pages keeps its name: ACLED's own notice still says
"page" under cursor pagination, and renaming would churn one script and
ten call sites for nothing.

Tests: 17 failed on the bare implementation. The fix for 8 of them was
one defaulted argument — _make_acled_response(events, next_cursor=None)
— so a single-response test now describes a complete walk. The rest
needed real work, and two needed care: test_mid_pagination_token_refresh
and test_401_mid_pagination_refreshes_token are the ONLY coverage of the
token-refresh and 401-recovery branches inside pagination, and both
would have gone green as single-page walks while testing nothing. They
carry live cursors instead.

Six new tests, each aimed at a mutant:
  - full final page + null cursor terminates (the old code could not
    have passed this)
  - short page + live cursor continues
  - next_cursor == 0 is live, not terminal
  - cursor threads through three pages, asserting REQUEST COUNT, so
    `cursor = 0` in place of `cursor = next_cursor` cannot pass
  - empty page + live cursor raises; missing next_cursor key raises
  - no events + null cursor returns [] without raising

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
…sting (#487)

Measured: 7609 events, 8 requests each way, identical on all four
oracles, content digest ae4a6e815168e95f both times. After 2026-10-27
this comparison cannot be made by anyone, so the recording is the
deliverable, not the number.

scripts/verify_acled_pagination_parity.py holds both walks, with the
offset loop as a frozen local copy rather than an import — the same
construction and the same reason as verify_area_majority_projection.py:
importing production would mean a later change there silently moves the
baseline the measurement was taken against.

The detection drill is a precondition in main(), not a flag. Four
drills: identical input reports parity; a dropped event trips all four
oracles; a changed FIELD trips records_equal even where counts and id
sets agree; and the two walk implementations actually diverge on input
designed to separate them. Verified by sabotage — forcing
"parity": True, and separately deleting the short-page break from the
frozen offset walk — both caught, exit 1. Without it, "the digests
match" is indistinguishable from hashing the same list twice, and the
existing 22 ACLED mocks cannot substitute: they serve an ordered list
without inspecting the request, so they cannot tell a correct walk from
one handed the right answer by accident.

Fixtures are a SEPARATE narrow slice (Somalia, 84 events, 113 KB) and
--write-fixtures is off by default. The headline walk recorded 9.9 MB
per side against a repo whose largest fixture is 9.4 KB. Trimming
events would have broken the digest oracle and shrinking the headline
slice would have weakened the evidence, so the two artifacts get two
slices. Logged as a deviation in the pre-analysis plan rather than
quietly re-scoped.

test_acled_cursor_parity_fixture.py replays the real envelopes through
the real fetch_paginated — 31 event fields, 9 envelope keys, the actual
opaque cursors in the actual order. It carries its own truncation drill,
because every other assertion in it would also pass against a loop that
stopped early.

Hypotheses resolved. H1 parity holds. H2 next_cursor is top-level and an
INT (so 0 is falsy and `is None` is mandatory). H3 null exactly once,
last. H5 count/total_count STILL null on every page — so C-153 is
amended, not closed, and ADR-027's tolerance rule still cannot apply to
ACLED. H6 no empty page with a live cursor.

H4 came back NEGATIVE: no page arrived short while the cursor was live,
so the deleted break would have agreed on this slice. Recorded as a
negative result rather than omitted. The deletion is structurally right
but not vindicated by this measurement — the honest basis for it is
mutant M1 being caught, not H4.

All 8 termination mutants caught (6 planned, plus M7 dropping the
empty-page raise and M8 terminating on falsiness, which the int cursor
made a live hazard the planned six did not probe).

Register: C-153 amended — it named the short-page heuristic as ACLED's
only detection signal, which this change deletes. C-359 added for the
gap the planning surfaced: n_events is written to the ledger and never
read, so a non-zero shortfall is recorded as success (#488).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
…e parity (#487)

Four findings from /code-review medium on #490. Three were right about
real defects; the fourth was right about where an entry was filed.

**The walk had no termination bound left (medium).** Both old
terminators are gone and `page` is now only a counter, so the exits are
`next_cursor is None`, the two raises, and `max_pages` — which
production never passes. `_fetch_single_year` calls fetch_paginated with
no cap; only the --proof probe sets one. If ACLED ever echoed a cursor
back, or reset it after a restatement, the loop re-requested the same
full page forever: unbounded duplicates, one request per page_delay,
against a rate-limited API, in an unattended monthly cron. Under offset
pagination `page += 1` guaranteed forward progress; under cursor
pagination only the server can.

The review also caught the inconsistency that makes this embarrassing
rather than merely missed: the parity script gave its own walk
MAX_REQUESTS = 40 with a comment explaining exactly this risk.
Production had nothing.

Guard is a seen-set rather than `next_cursor != cursor`, because it also
catches a longer cycle (A→B→A) and 80 integers costs nothing. Seeded
with {0} so a response echoing the starting cursor is caught too — and
that case is why test_zero_cursor_is_not_treated_as_terminal is gone.
It asserted that a walk continues after next_cursor=0, which this change
reclassifies as a stalled walk. Replaced by
test_echoed_zero_cursor_raises_not_terminates, which is a better test:
one scenario now separates three behaviours — correct code raises,
`if not next_cursor: break` returns silently, and no guard loops
forever. M8 is still caught.

**The empty-page raise can abort a harvest on a benign boundary (low).**
A slice holding an exact multiple of `limit` yields a final full page
followed by an empty one. If ACLED echoes a live cursor there, the year
fails. Odds are ~1-in-page_size per year-slice, so non-trivial across a
28-year re-fetch, and never observed — H6 was confirmed on one 8-page
and one 2-page walk.

Kept the raise rather than taking the suggested retry-once: fail-loud is
the governing principle and the consequence is a failed harvest, not a
wrong one. But the message now names the exact-multiple boundary as the
likely cause and says what to check, so whoever hits it does not start
by suspecting ACLED.

**The parity script could report PARITY on two truncated walks (low).**
Both walks stopped silently at MAX_REQUESTS and _compare then compared
two prefixes, which agree on all four oracles. On a slice larger than
40×limit the script logged "PARITY: both walks returned identical data"
and exited 0 — the one verdict the pre-analysis plan reads as licence to
ship. That is the error class this entire investigation is about,
committed by its own instrument.

Walks now return hit_cap, the report records it, a capped run forces a
non-parity verdict and exits 1 as INCONCLUSIVE, and drill 5 asserts the
flag fires — verified by sabotage, like the other four.

**C-359 was filed under "Deferred by Design" (low).** Moved into the
Tier 3 section where the header counts it and where every other Tier 3
entry lives. My error: I picked an insertion anchor without checking
which section it was in.

Mutants: 8 → 11, all caught. M9 drops the progress guard, M10 never
records a cursor as seen, M11 seeds the set empty instead of with 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
…amp both measurements (#487)

Two of this document's claims had gone stale and one of its artifacts
could not be traced to the code that made it.

Section 3.2 said a restatement-reset cursor was an open question. It
still is — but the worst version of it now fails loud instead of
looping, so the section says what was closed and what was not. Section
3.1 gains the one trigger for the empty-page raise that is both benign
and foreseeable: a slice holding an exact multiple of limit. Roughly
half a percent odds across a 28-year re-fetch, presenting as a failed
year rather than a wrong one.

New section 5 records all five review findings, including the two worth
keeping visible rather than quietly fixing:

The parity script could have reported PARITY on two truncated walks —
truncation mistaken for completion, in the instrument built to detect
truncation mistaken for completion. It never affected the recorded
results (both walks ended naturally in 8 and 2 requests) but it could
have on a larger slice, and parity.json now states
walks_ran_to_completion rather than leaving it inferable.

And parity.json's git_sha named 13ddda1, which does not contain the
script — it landed in eea3068. For a file whose whole purpose is
evidence that cannot be re-collected, that stamp was close to useless.
Both measurements re-run at 47089fd.

The re-run reproduced BYTE-IDENTICALLY: 7609 events, 8 requests each
way, digest ae4a6e815168e95f, and the fixture recording's md5 is
unchanged — which also confirms ACLED has not restated this slice. That
corroboration was not planned; it fell out of fixing the stamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
… itself (#487)

Iteration 2 of the ritual. Two warnings, three suggestions, no
criticals. All taken.

**A non-scalar next_cursor crashed with a bare TypeError.** The
progress guard tracks cursors in a set, so a list or dict cursor hit
`TypeError: unhashable type: 'list'` out of the membership test —
verified empirically. Every other envelope anomaly in this loop names
the field and says what to check; this one gave an unattended cron a
stack trace with nothing pointing at ACLED. A type change is the same
class of provider drift as the missing key already guarded two lines
up, so it now gets the same named ValueError. New mutant M12.

**max_pages now beats a stalled cursor.** The guard ran first, so a
capped walk raised on a cursor anomaly it was about to stop for anyway
— verified: max_pages=1 with an echoed cursor raised instead of
returning its page. Only `harvest_acled.py --proof` passes a cap, so
this was `--proof` reporting FAIL for a credential that plainly works.
The cron passes no cap, so the ordering change cannot weaken it.

**The parity verdict had two owners.** `_compare` returned
`parity: all(checks.values())` and then main() overrode it to False and
added a key to `checks` after that aggregate was taken. No live bug,
but a check added inside `_compare` later would have sat outside
main()'s override logic. Completion is now an oracle passed INTO
`_compare` rather than a correction applied after it.

**And that refactor opened a hole the drill did not see.** Drill 5
proves the WALKS set hit_cap; nothing proved `_compare` acts on it.
Hard-coding `walks_ran_to_completion: True` inside `_compare` passed the
whole drill cleanly — found by sabotage, which is the only reason it was
found at all. Drill 6 closes it: identical data with
walks_completed=False must not report parity. Re-sabotaged, now caught.

Also: F1's stale "Three drills" docstring, and a note that `_fake_fetch`
ignores params so no drill can catch a malformed request — stated so
"drill passed" is not later read as broader than it is.

**Unrelated guard tripped, and the fix is precedented.**
test_falsification_ghsbuilts_coverage_parity F1 requires GHS-BUILT-S to
hold >=70% of its siblings' test-function count. The 12 tests added here
moved the denominator 245 -> 257 and the ratio 73% -> 69%. GHS-BUILT-S
did not regress — it is 178 functions before and after. F2 was lowered
70% -> 65% on 2026-06-29 for exactly this reason; F1 now follows, with
the trigger recorded in its docstring. Adding GHS-BUILT-S tests to
restore a ratio would be gaming the metric. Both F1 and F2 also had
failure messages claiming 70% while enforcing a different number.

Mutants: 11 -> 12, all caught. Drills: 5 -> 6, each verified by sabotage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9
@Polichinel
Polichinel merged commit 9175fe7 into development Oct 12, 2026
6 checks passed
@Polichinel
Polichinel deleted the feat/acled-cursor-pagination branch October 12, 2026 00:30
Polichinel added a commit that referenced this pull request Oct 12, 2026
…dies 27 Oct (#487) (#491)

The first release whose point is a deadline rather than a feature. ACLED
deprecates offset pagination on 27 October 2026; the harvester walked their
API by `&page=N`.

In the wheel (#490):
  - fetch_paginated() in acled.py walks `&cursor=X` and terminates only on a
    server-declared `next_cursor is None`
  - the empty-page and short-page breaks are deleted — under cursor
    pagination a short page is not the end of the series, and treating it as
    one is how this migration would have truncated a harvest silently
  - the walk is bounded by a non-advancing-cursor guard, so a stalled cursor
    raises instead of looping against a rate-limited API in an unattended cron
  - AcledConfig is unchanged: no field added or removed

Why now and not later: the failure shape. A missed migration does not crash on
the 27th — it surfaces on the 21 November cron as a short harvest, quiet, a
month late, and in the dataset rather than the logs. Deploying before the
21 October run means the new code runs in production while offset still works
at ACLED, so `git checkout v1.13.0` remains a live revert target.

Parity was measured while both methods still worked: 7,609 events, 8 requests
each way, identical on count, event_id_cnty sets, full records and
content_digest (ae4a6e815168e95f), reproduced byte-identically at a second
commit. Real envelopes from both walks are committed as fixtures — after
27 October the offset side can never be recorded again.

Not fixed here: a short harvest is still recorded as `outcome: "success"`
because nothing reads `n_events` back (C-359, #488). C-153 is amended, not
closed — ACLED still returns count/total_count as null.


Claude-Session: https://claude.ai/code/session_01EfeuULbPTqrx4qtNki5dy9

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant