Skip to content

PR 5 — Push-event ingestion - #26

Merged
batbrainy merged 17 commits into
mainfrom
pr-5-push-event-ingestion
Jul 30, 2026
Merged

PR 5 — Push-event ingestion#26
batbrainy merged 17 commits into
mainfrom
pr-5-push-event-ingestion

Conversation

@batbrainy

Copy link
Copy Markdown
Owner

Linked issue

Closes #15IMPLEMENTATION_PLAN.md §13, PR 5.

Problem

PRs 1–4 built everything around ingestion and nothing that ingests. The request chain
works, the tables and their idempotent write primitives exist, both event-source adapters
decode a page — but no code path joins the two halves. There is no processor, no fingerprint
function, no transaction that turns an envelope into rows, no run lifecycle, and no way for a
human to trigger a poll. docker compose up starts a service that never fetches anything.

Scope

In: the processor registry and the tolerant PushEvent parser; the quarantine taxonomy
with canonical fingerprints; the ingest transaction — stub entity upserts under §7's envelope
mappings, idempotent insert, RETURNING-gated activity updates; ingestion-run summaries with
run_id correlation in the logs; and the one-shot command with §9's contention contract and
state summary.

Deliberately out:

  • PR 6Link pagination (page one only), ETag persistence, effective_poll_time and
    cadence gating, global_blocked_until, Retry-After, and every event_sources poll-state
    column. ingestion_runs is PR 5's poll history and is strictly richer than a
    last_polled_at scalar.
  • PR 7 — every enrichment_status transition, skipped_budget, reactivation, TTLs.
  • PR 8 — Solid Queue, jobs, post-commit enqueue, the reconciler, the worker container.
  • PR 10/status. PR 5's state summary is plain stdout.

No migration. Every column needed already exists.

Technical decisions

One transaction per envelope, not per page. §16 requires that malformed data "does not
terminate the batch", and PostgreSQL aborts an entire transaction on any failed statement —
a hazard spec/support/constraint_helpers.rb already documents in this repo. A page-wide
transaction would let one unexpected error on envelope five of eight discard the four the log
had already reported as persisted. §8 asks for the same shape from the other direction: its
durability boundary is per event, and step 9 calls the transaction "short-lived". It also
keeps PR 6 additive — no transaction is ever open across a fetch, at any page count.
Quarantine writes stand outside any transaction, so a quarantine record can never be
discarded by a later failure.

Interpretation lives inside the same per-envelope boundary as the write. Classification is
pure but not infallible: an envelope carrying a value JSON cannot represent has no
fingerprint, so the fingerprinter raises rather than invent one. Interpreting a page in one
pass and writing in another would let that single envelope take the whole page down.

Activity gating is the whole of §7 merge rules 3 and 4. touch_activity! runs only when
insert_if_new returned a row; on a duplicate the transaction still commits, carrying rule
1's identity refresh and nothing else. §7 rule 3 also mentions applying skipped_budget
reactivation, but §13 assigns that transition to PR 7 and Enrichable's own comment says
"Nothing here changes enrichment_status" — and PR 5 cannot produce a skipped_budget
entity, so the branch would be unreachable. PR 5 ships the gate; PR 7 adds the transition at
that one call site.
Where §7 and §13 disagree about when, §13 decides, per CLAUDE.md's
per-issue protocol.

The fingerprint covers the whole envelope, not envelope["payload"]. §7 requires the same
github_event_id with a different malformed payload to be a different row; corpus event
58000000007 carries "payload": {}, so fingerprinting only the payload would collapse
every typeless envelope with an empty payload onto one row and discard the rest permanently,
since record! never refreshes raw_payload. JSON.generate, not to_json, because
ActiveSupport's encoder applies as_json coercions and HTML-escapes under an
application-configurable flag — one initializer away from silently re-keying every row in the
table.

push_events_seen counts envelopes GitHub typed as pushes, whether or not they
normalized (§8 step 4), so page 1 gives 6 and not 4. The rejected alternative — "everything
not positively identified as another type" — would make events_ignored exactly derivable
from the stored columns, but calling a typeless envelope a push event seen misrepresents the
column.

events_ignored is logged and printed but not persisted. §7's ingestion_runs field list
has no column for it and PR 3 owns the schema. It is not reconstructible from the stored
counters either, because a typeless envelope is quarantined rather than ignored — stated
plainly rather than implied.

A parser escape counts as events_failed, not as a quarantine. PushEvent.insert_if_new's
own comment says reaching its validate! "means the parser let something through". §7 defines
quarantine as a classified predicate and error_code is never refreshed on replay, so an
"unclassified" row would be a permanent lie. A table-driven spec proves the path unreachable
across the whole taxonomy.

No GITHUB_EVENT_TYPES. §6 offers it as an option, one processor ships, so its only legal
value is its default — a knob that can only be set wrong (§16: no speculative
infrastructure). The validation §6 requires exists without it:
ProcessorRegistry.for refuses an unimplemented type with a configuration error naming what
is implemented. Documented in .env.example's "deliberately absent" section.

--force is accepted and inert. What §9 says it bypasses is PR 6's, and neither the
cadence nor the stored ETag exists to bypass. Deferring the flag would mean a reviewer typing
the documented command gets a usage error — the worst outcome for §16's reviewer-experience
gate. It is recorded on the run's log line, --help says plainly that cadence gating and ETag
reuse land with PR 6, and the CLI surface will not change when they do.

Three exit codes. 0 for ran or deferred — §9 pins it for a busy source, and §10's
reasoning extends it to a budget denial, a held gate and a rate-limit response, all of which
mean the request never happened. 1 for an attempt that failed. 2 for refusing to run: a
bad option, a bad configuration, or a corpus gap.

Two facts were probed against PostgreSQL 16 rather than assumed, and both changed the code.
A String carrying a NUL byte is refused (ArgumentError from the driver for a text column,
PG::UntranslatableCharacter inside jsonb) — that is the events_failed path the isolation
spec exercises. Float::INFINITY, by contrast, is written into jsonb as null without
raising, so a healthy envelope carrying one persists with that field nulled, inside ADR 0001's
semantic-retention tradeoff. Earlier comments claiming it could not be stored were wrong and
were corrected.

Two small changes to files this PR does not own, both additive: an optional context: on
EventSources::Base#request_for, so run_id reaches the DEBUG github.request line without
the executor or the formatter knowing about it (the adapter's source_type merges last, so a
caller cannot impersonate it); and the status vocabulary on IngestionRun, which PR 3's model
comment explicitly deferred to this PR. No CHECK constraint accompanies the enum — that needs
a migration and PR 3 owns the schema.

Testing performed

758 examples, 0 failures. RuboCop clean, Brakeman clean, bundler-audit clean.

New specs, all deterministic and offline:

  • Fingerprint — §7's full case table, asserted against the pinned canonical string
    rather than opaque hex so each example documents the algorithm, plus one golden digest as a
    library-upgrade tripwire.
  • Taxonomy — every code maps to a §7 row, every row is exercised, and precedence is
    asserted at each boundary, including the one that matters: a String "1296269" against the
    Integer 1296269 must report the unusable shape, not a mismatch that does not exist.
  • Processor — a table of ~30 near-identical envelopes differing in one field; both
    directions of the model-union invariant (every accepted attribute set validates against all
    three models; every rejectable shape quarantines instead).
  • Page writer — the replay case six ways, including the structural one (touch_activity!
    is never called) and a planted skipped_budget entity that must survive; failure isolation
    proving a events_failed envelope loses neither the events around it nor an earlier
    quarantine row.
  • Runner — the corpus page end to end through the real gate, ledger and URL policy;
    304, budget denial, held gate, rate limits, retries recovering a page without processing it
    twice, an unusable body, and a source another session owns. Plus an explicit assertion that
    no application transaction is open at the moment the transport is called.
  • One-shot — every outcome's exit code and printed lines, over injected IO, with the state
    summary asserted present on every path.
  • docker_compose_spec.rb — §16's "plain up starts exactly db, setup, web" gate,
    which PR 2 shipped untested and one mistyped profile key silently breaks.

A missing require "optparse" survived all 758 examples and died on the first real
invocation
— rspec loads it through another gem. No unit test could have caught it, because
the suite boots Rails through rails_helper and never exercises the command's own boot path.
bin/ci and the Actions workflow now run bin/ingest once, in fixture mode.

Docker verification

Against the built image, after the fix:

Path Result
live 100 events seen, 98 push events created, 2 non-push ignored, exit 0
fixture, first run 4 created / 3 quarantined / 1 ignored, exit 0
fixture, second run 0 created / 4 duplicates; psql: 4 push_events, 3 actors, 3 repositories, 3 quarantined, 6 occurrences
busy source lock held by a second PostgreSQL session → source busy — poller cycle in progress + state summary, exit 0, no ingestion_runs row, and the ledger byte-identical before and after — the receipt proving no request was issued
deferred Ingestion deferred until 2026-07-30T15:24:13Z — class_allowance_exhausted, exit 0, run recorded as deferred with no last_error
usage --help exits 0; --turbo and a stray positional exit 2 on stderr

docker compose config --services still prints exactly db setup web; --profile tools
prints five.

Documentation updates

  • New ADR 0005 — at-least-once processing with idempotent writes. §14 lists it and this is
    the PR where the write path it describes lands.
  • README — the status section ("Nothing polls GitHub yet" stopped being true), a new
    One-shot ingestion section with both output blocks, the exit-code table, the busy contract
    and the fixture walkthrough with exact counts; IngestionRunner and PageWriter added to
    the request-path section; two "Planned contents" rows corrected — the one-shot row promised
    --force to PR 5, so it is replaced by the section plus an explicit PR 6 row for the half
    that is missing.
  • .env.example — three notes, no new variables: the poll allowance already caps
    one-shot runs at twelve an hour, the fixture cursor is per process (which is what makes
    duplicate absorption demonstrable by hand), and GITHUB_EVENT_TYPES joins the "deliberately
    absent" list with its reasoning.

Known limitations

  • At-least-once execution, effectively-once persisted outcomes. Not exactly-once, and the
    documentation never says otherwise (§8, §16).
  • One page per run. MAX_PAGES_PER_POLL is an allowance-formula input until PR 6 wires
    Link pagination.
  • A single hostile event can fail an entire page's decodeJSON.parse nesting beyond
    100, or invalid UTF-8. Decoding is all-or-nothing by nature, and it maps cleanly to §7's
    taxonomy row 5 (a request failure, not an individual quarantine).
  • A crash mid-page leaves ingestion_runs.status = "running" with zeroed counters, because
    counters are accumulated in memory and written once. Nothing sweeps stale running rows —
    that is PR 8's reconciler. The run row is a summary artifact; §8's durability boundary is the
    committed push_events row.
  • event_sources.source_type is not unique — §6 anticipates per-repository sources and a
    PR 3 spec asserts several rows of one type are allowed — so two processes racing on a fresh
    database can both provision. The provisioner converges on the lowest id, which keeps the race
    harmless (both derive the same advisory lock key); the cost is one extra row and one extra
    poll attempt. Worth a constraint decision in PR 6, which owns that table.
  • Entities are stubs. Enrichment is PR 7, so every actor and repository stays pending.

🤖 Generated with Claude Code

batbrainy and others added 16 commits July 30, 2026 08:39
SHA-256 of compact UTF-8 JSON produced after recursively sorting all object keys.
One definition, no alternates — §7 says so twice and CLAUDE.md repeats it, because a
second algorithm would mean two rows for one payload and an occurrence count that no
longer counts occurrences.

Because payload_fingerprint is the *only* unique constraint on quarantined_events
(§7, Appendix D item 7 — a malformed event may be malformed precisely because it
lacks an event ID), this is a primary key, and a primary key has to be a pure
function of its input. So: no clock, no database, no configuration, and no
dependence on Hash iteration order.

What is fingerprinted is the whole envelope element, the same value written to
raw_payload — not envelope["payload"]. Corpus event 58000000007 carries
"payload": {}, so fingerprinting only the payload would collapse every typeless
envelope with an empty payload onto one row and discard the rest permanently, since
record! never refreshes raw_payload or error_code. Two specs pin that: the same event
ID with a different actor is a different identity, and an envelope with and without
its id are different identities.

JSON.generate, deliberately, not to_json. to_json routes a Hash through
ActiveSupport's encoder, which applies as_json coercions and HTML-escapes < > & when
ActiveSupport.escape_html_entities_in_json is true — an application-level setting.
Both are deterministic today and both are one initializer away from silently re-keying
every row already in the table.

The function is total over anything a JSON array element can be, because
EventSources::Base#events returns elements untouched including null, and §7 requires
those to be quarantinable: nil, [], {}, a bare String and a bare Integer all
canonicalize. Three inputs raise instead: a non-String object key (unreachable from
JSON.parse, and the one input that would make sort order ambiguous — sort_by is not
stable), a value outside the JSON data model, and a non-finite Float.

The last one is a real path, not a curiosity. JSON.parse("1e400") succeeds and yields
Float::INFINITY; JSON.generate then raises JSON::GeneratorError, and ActiveRecord
serialises jsonb through the same generator — so such a payload can be neither
fingerprinted nor stored, and its only terminal outcome is events_failed. Naming it
here means the log line says what happened.

Numeric representation is deliberately not normalized: 1 and 1.0 fingerprint
differently, pinned by a spec so nobody "fixes" it. Over-splitting costs one extra
row; merging them would fold two genuinely different payloads onto one row and lose
one of them for good.

Assertions are written against the canonical string rather than opaque hex, so each
example documents the algorithm. One golden digest is kept as a library-upgrade
tripwire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§7's table has five rows, three of which quarantine. This turns them into ten codes,
a TAXONOMY hash naming which row each code implements, and a declared precedence
order — so the vocabulary is a refinement of the plan that a spec can check, rather
than a handful of strings someone typed.

CODES is derived from TAXONOMY, which means a new code cannot be added without naming
its plan row, and the spec asserts every row is exercised in both directions.

Precedence is declarative rather than a side effect of the order the processor happens
to run its checks in, because QuarantinedEvent::OCCURRENCE_MERGE deliberately never
refreshes error_code: the first classification is permanent, so the winning code has
to be a property of the taxonomy and not of control flow. Order is structure, envelope
identity, payload presence, payload shape, integrity, range. Two boundaries carry the
weight:

Envelope identity before payload — the quarantine row is indexed by github_event_id
and carries event_type, and both come from the envelope, so a row classified by a
payload defect would be the less useful of the two available facts.

Shape before integrity — with payload.repository_id == "1296269" (a String) and
repo.id == 1296269, Ruby's != is true, so an integrity-first order would report a
mismatch that does not exist while shape-first names the actual defect. Spec'd
directly.

There is deliberately no "could not classify" code. Such a row would be a permanent
lie about the taxonomy, since error_code is never revised; an unexpected error on the
write path is counted as events_failed instead, which is also what
PushEvent.insert_if_new's own comment asks for ("reaching this raise means the parser
let something through").

identifier_out_of_range has no §7 row and is called out as a refinement of "present
but unusable". It cannot be dropped: the four identifier columns are bigint, nothing
validates numericality, and an over-range value would otherwise reach PostgreSQL and
become an unclassified failure instead of a classified quarantine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§7's tolerant parser: it requires repository_id, push_id, ref, head and before,
tolerates unknown fields because GitHub can add response fields without a new API
version, and retains the entire envelope in raw_payload regardless.

The required-field set is deliberately wider than those five. It is the union of §7's
list and every NOT NULL or validated attribute of the three models the write path
touches — push_events' nine columns plus SHA_FORMAT, github_actors' github_id and
login, github_repositories' github_id and full_name. Both upsert_stub! methods call
validate! precisely so a malformed envelope cannot abort the ingest transaction, so
anything they would reject has to be classified here instead: without the actor.login
and repo.name clauses a real envelope with a null login would land in events_failed
rather than in the taxonomy. A spec asserts both directions — every accepted attribute
set validates against all three models, and each rejectable shape quarantines instead.

Outcome is the transaction boundary, not a convenience. assert_committable! forbids a
live request inside an application transaction, so PR 5's shape is fixed: interpret
outside the database, write inside it. The three attribute Hashes carry exactly the
keyword arguments of PR 3's model writers, so the writer body is
upsert_stub!(**outcome.actor_attributes) and insert_if_new(outcome.push_event_attributes)
— the models keep sole ownership of their merge rules and Outcome knows no SQL. There is
no :failed kind, because a failure is discovered while writing, not while interpreting.

push_type? is on the Outcome rather than in the counter, so push_events_seen counts
envelopes GitHub typed as pushes whether or not they normalized — six of page 1's eight,
not four.

The processor is pure: no clock, no database, no network. That is required for
correctness, not just for test speed — OCCURRENCE_MERGE never refreshes error_code, so
the first classification of a payload is permanent and must not depend on anything
outside the payload.

Envelope holds the two readers the registry and the processor both need, so the rule for
reading an event's id and type cannot drift between routing and normalizing.

Time.iso8601, not Time.zone.parse: the latter answers nil for junk, which would travel
as far as a NotNullViolation inside the ingest transaction instead of being classified.

The integrity comparison runs only when both identifiers parsed as Integers. A String
"1296269" is != the Integer 1296269, so an unguarded comparison would report a mismatch
that does not exist and hide the real defect behind a fabricated one. Spec'd directly.

#call never raises for malformed data, with one honest exception: a non-finite Float
from a literal like 1e400 has no fingerprint and no jsonb representation, so it has no
quarantine row available to it. Both halves of that boundary are pinned — it raises when
it would have to fingerprint one, and it does not fingerprint a healthy envelope, so an
unstorable value in an unknown field surfaces at the write and lands in events_failed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§6's second seam. It deliberately mirrors EventSources::Base.registry / .for down to the
shape of the error message, so a reviewer who has read the first seam recognizes this
one: both answer which implementation serves a name, and what happens when none does.

The registry owns exactly the type-agnostic half of §7's taxonomy — element is not an
object, no usable type, a type nothing implements, otherwise delegate — so a future
processor declares its own payload requirements without touching this file.

Type before field checks is load-bearing. Roughly nine in ten events on the global feed
are not pushes; running payload checks first would quarantine all of them for lacking
payload.head, and §7 says a valid non-PushEvent is "Ignored and counted — not
quarantined".

A malformed event of an unimplemented type is still ignored, and that is a decision. This
application has no WatchEvent processor and therefore no definition of a valid WatchEvent;
validating fields it never persists would invent a schema for twenty-odd unverified event
types and fill the quarantine table with rows no operator can act on. For an unprocessed
type, "valid" can only mean "identifiable" — a Hash with a nameable type. An element that
is not a Hash, or whose type is not a String, fails that and is quarantined.

.for(event_types) is §6's "Configured event types must be validated against implemented
processors. Unsupported types should fail fast with a clear configuration error", and it
is what §12's "Processor registry validation" test exercises. There is deliberately no
GITHUB_EVENT_TYPES environment variable: one processor ships, so the only value that
would pass validation is the default, and §16 forbids dead or speculative infrastructure.
The day a second processor lands it is one DEFAULTS entry and one #validate! line, with
this method already in place. The omission is documented in .env.example's existing
"Deliberately absent, and not oversights" section.

The processor contract is checked by duck-typing real objects rather than by an abstract
base class with one subclass — the same speculation critique Appendix A item 7 upheld the
adapter design against.

One spec is about the fingerprint's scope rather than the registry: two typeless
envelopes sharing an event id but differing in actor produce different identities, which
is what §7's "a different malformed payload is a different quarantine row" requires and
what fingerprinting only envelope["payload"] would have broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The counters of one run as a value object, so the arithmetic is assertable with no
database. Two identities hold for every run and both are spec'd against page 1 of the
corpus:

  events_received  = push_events_seen + events_ignored + invalid_envelope_quarantines
  push_events_seen = events_created + duplicates_skipped + push_quarantines + events_failed

Immutable, so #record returns the next tally rather than mutating this one — a counter
cannot be bumped from two places by accident, and a spec asserts the receiver is
unchanged.

push_type travels separately from result because push_events_seen counts what GitHub
*typed* as a push whether or not it normalized (§8 step 4 filters PushEvent entries). Page
1's eight envelopes therefore give push_events_seen 6, not 4: the two malformed pushes are
still pushes that were seen. The alternative reading — "everything not positively
identified as another type" — would make events_ignored exactly derivable from the stored
columns, and is rejected because calling a typeless envelope a push event seen
misrepresents the column.

events_ignored has no column. §7's ingestion_runs field list has none and PR 3 owns the
schema, so it is logged and printed but not persisted — and it is honestly not
reconstructible from the stored counters, because an envelope with no usable type is
quarantined rather than ignored, so events_received - push_events_seen counts both.
persistable_attributes and to_log make that split visible at the call site rather than
hiding it, and a spec asserts persistable_attributes matches IngestionRun::COUNTERS exactly
and produces a valid record.

pages_fetched counts a page that decoded into an array and reached the processor, so a
304, a deferral, a transport failure and an undecodable body all contribute nothing. The
attempt itself is already counted by github_api_budget.poll_used and by the DEBUG request
line, and this way an undecodable page stays distinguishable from an empty one.

Report carries the shared stdout formatting. §9 prints two blocks produced by two
different objects and a reviewer reads them as one report, so the column width lives in
one place — 34, which is the width that reproduces all four of §9's sample lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR 3 deferred the vocabulary here in the model's own comment. Five states:

running        created before the fetch, completed_at still NULL — which is what a
               process killed mid-run leaves behind, and the honest crash signal.
completed      a page was fetched and every envelope reached a terminal outcome,
               quarantines and per-event failures included.
not_modified   a 304. §10: no event processing runs and the reservation stays debited.
               Distinct from completed because "the feed had nothing new" and "the feed
               returned an empty page" are different facts an operator must be able to
               tell apart.
deferred       the request never happened — budget denial, busy gate, rate-limit
               response. §10 is explicit that these are not failures, so folding them
               into failed would burn a healthy source's failure count on a busy system.
failed         the attempt happened and did not produce usable events.

There is deliberately no busy status: the run row is created inside the source lock, so
Errors::SourceBusy provably cannot leave one behind.

enum … validate: true matches Enrichable and GithubApiBudget, the two vocabularies already
here. Unlike those two there is no matching CHECK constraint — one needs a migration and
PR 3 owns the schema — so the validation is the guard and the constraint is a one-line
follow-up whenever migrations are next touched. Called out rather than left as an
inconsistency.

run_id is generated in Ruby and passed on insert. §11 requires it on the correlated log
lines, the first of those is emitted as the run starts, and this codebase has no tagged
logging or log-context helper — so the value has to exist before the row does.
gen_random_uuid() stays as the column default and remains the safety net for a
hand-written insert, which is exactly what PR 3's model spec asserts; reading it back
instead would cost a SELECT and produce a run_started line with no correlation id on it.

Counters are written once in finish!, from the tally. Not incrementally and not inside the
per-envelope transaction: §7 calls this row a record of one polling cycle and §8 puts the
durable record elsewhere, so updating it per envelope would put a hot row and a held row
lock inside the write path in exchange for diagnostics push_events.created_at already
provides. A running row with a NULL completed_at is also more honest than counters that
could disagree with the events which actually committed.

latest_successful excludes unfinished runs as well as unsuccessful ones, because §9's
"Latest successful run" line prints completed_at and a run still in flight has none.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§8 steps 4–9. The constructor takes a registry and a clock, and deliberately no executor
and no event source, so a live GitHub request cannot physically be issued from inside its
transaction — the same structural technique PR 4 used when it never handed RequestExecutor
an event_source_id. assert_committable! and LockOrder are the enforced halves.

One transaction per envelope, not per page. §16 requires that malformed data "does not
terminate the batch", and in PostgreSQL a failed statement aborts the entire enclosing
transaction — a hazard spec/support/constraint_helpers.rb already documents here. With a
page-wide transaction, one unexpected error on envelope five of eight would discard
envelopes one to four that the log had already reported as persisted, along with the
quarantine rows written for the others. §8 asks for the same shape from the other
direction: its durability boundary is per event, and step 9 calls the transaction
"short-lived — the advisory lock, not the transaction, spans the HTTP work". It also keeps
PR 6 additive: no transaction is ever open across a fetch, so a Link-driven page loop needs
no change here. What it gives up is page-level atomicity, and nothing needs it —
github_event_id uniqueness makes reprocessing a partially written page a no-op, which is
why §9 requires every fetched page to be processed in full.

Interpretation is inside the same per-envelope boundary, which is why this is a page writer
rather than the outcome writer the plan sketched. Classification is pure but not
infallible: an envelope carrying a value JSON cannot represent has no fingerprint, so the
fingerprinter raises rather than invent one. Interpreting the page in one pass and writing
in another would let that single envelope take the whole page down — the exact failure §16
forbids. Github::Events::Outcome#push_type? was dropped in the same move: the writer reads
the type from the envelope, so push_events_seen is one number computed one way whether the
envelope persisted, duplicated, quarantined or failed.

Activity updates happen only when RETURNING produced a row (§7 merge rule 3, Appendix D
item 5). On a duplicate the transaction still commits, carrying rule 1's identity refresh
and nothing else, so rule 4 holds structurally rather than by a later check. Six specs
cover it, including the structural one — touch_activity! is never called on a replay — and
one that plants a skipped_budget entity and proves the replay cannot reactivate it. The
replay specs use update_all with updated_at held at the frozen instant, because
IDENTITY_MERGE gates each assignment on EXCLUDED.updated_at >= the stored value and a
wall-clock touch would make them pass for the wrong reason. last_seen_at is asserted with a
later instant and first_seen_at with an earlier one, since GREATEST and LEAST respectively
make the other direction vacuous.

Quarantine is one statement outside any transaction, so a quarantine record can never be
discarded by a later failure — a property a page-wide transaction would not have. It logs
at INFO rather than DEBUG: §11 puts per-event lines at DEBUG, but the issue requires GitHub
event IDs in the logs and §16 requires malformed data to be visibly quarantined, and an
operator should not have to change log levels to learn that events are being rejected.

Ignored events write nothing at all, not even stub entities: §8 quarantines at step 5 and
upserts at step 6, so only envelopes that normalized produce entities. That is what makes
page 1 give three actors and three repositories rather than four of each — and it matches
the corpus, whose enrichment fixtures are exactly those three users and three repositories.

FATAL_ERRORS re-raises rather than absorbing: a locking or transaction invariant is a
programming error whose own doc comment asks it to be loud, and a dead connection is not a
property of any one envelope — reporting "events_failed: 8, completed" for either would be
a lie.

Two facts were probed against PostgreSQL 16 rather than assumed, and both changed what the
code says. A String carrying a NUL byte is refused (ArgumentError from the driver for a
text column, PG::UntranslatableCharacter inside jsonb) — that is the events_failed path the
isolation spec exercises. Float::INFINITY, on the other hand, is written into jsonb as null
without raising, so a healthy envelope carrying one persists with that field nulled inside
ADR 0001's semantic-retention tradeoff; the earlier comments claiming it could not be
stored were wrong and are corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A run needs an event_sources row and nothing seeds one. PR 4 already wrote down why
seeding is wrong, in BudgetLedger#bootstrap!'s comment: db/seeds.rb runs only when
db:prepare creates a database and db:test:prepare never seeds at all, so a seeded row
would exist in development and be absent in test — the worst possible split, and a
migration is wrong for the same reason since schema.rb carries no rows. So this follows
the pattern the ledger established rather than inventing a second answer: lazily, at the
point of use.

A boot initializer is also wrong. config/initializers/github.rb guarantees its validation
touches no database, which is what lets db:prepare, rails runner and CI's schema load work
before anything is migrated.

source_type comes from EventSources::Base.for_mode, which PR 4 built for exactly this and
says so — so the row a process provisions always matches the adapter it will poll with.

event_sources.source_type is deliberately not unique: §6 anticipates per-repository sources
and a PR 3 spec asserts several rows of one type are allowed. So provisioning cannot be made
atomic with an ON CONFLICT clause, and two processes racing on a fresh database can both
insert. Converging on the lowest id is what makes that harmless — both processes then derive
the *same* advisory lock key from the same row, so the source lock keeps protecting the
source, and the cost of the race is one extra row and one extra poll attempt with duplicate
events absorbed by uniqueness. Adding a constraint would contradict a committed decision and
belongs to PR 6, which owns this table.

status is written once as "idle" and never touched again: the column is NOT NULL with no
vocabulary because PR 6 owns the poll state machine. configuration stays empty, because §6
puts endpoint construction in the adapter and a URL stored here would be a second source of
truth able to disagree with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§11 lists run_id among the fields every correlated line carries, and the DEBUG
github.request line is exactly the line a reviewer turns on to trace one run. Today it
cannot carry one: EventSources::Base#request_for hard-codes context to the source type.

Three optional keyword arguments fix that, and nothing else changes. Request#to_log already
merges context flat and FetchResult#to_log already merges request.to_log, so the executor
and the formatter are untouched. Every default is unchanged and no existing spec inspected
Request#context, so the blast radius is this file plus two shared examples.

The adapter's own source_type merges last, so a caller's context cannot impersonate it —
asserted, because a log field that can be spoofed by its own payload is the mistake
JsonLogFormatter::RESERVED_KEYS already guards against at the formatter level.

Correlation matters more than it looks: from PR 8 the worker's scheduled poll and a
reviewer's one-shot interleave in one stdout stream, where correlating by timestamp
adjacency stops working. §16's operability gate asks for correlation fields, not for
correlation by inference.

Also renames the formatter spec's sample event to ingestion.run_started, matching the dotted
convention the two existing emitters already use (github.request, budget.window_rolled), so
the example stops reading as a naming precedent the code contradicts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§5 names this component and says what it owns: "IngestionRunner owns the source lock for
the duration of a polling operation. RequestExecutor does not own or acquire SourceLock."
So the lock is taken here, and the run row is opened *inside* it — which is what makes
"a busy source leaves no stray running row" provable rather than merely likely, and the
SourceBusy spec asserts exactly that: no run row, no request, no budget spent.

Two orderings carry the design.

The fetch sits between two writes with no transaction open, because assert_committable!
refuses a reservation inside one ("an outer rollback would refund a request GitHub has
already counted"). A spec asserts the property directly at the moment the transport is
called, rather than trusting the ledger's own guard — a future refactor that wrapped the
run in a transaction would otherwise fail only in production, where the example transaction
does not mask it.

Classification is branched on *before* the body is decoded. Base#events calls
JSON.parse(body.to_s) and a 304 carries no body, so the other order turns a perfectly
healthy 304 into a MalformedResponse and records a failed run. The 304 spec is what catches
that inversion.

Outcomes map to §7's and §10's vocabulary rather than to HTTP: 304 is not_modified, a
budget denial or a busy gate is deferred (§10: they "never happened", so folding them into
failed would burn a healthy source's failure count), a rate limit is deferred too because
nothing is wrong with the request, and an unusable body is a failed run with no quarantine
row — §7's taxonomy row 5 says an invalid response body is a request failure, not an
individual quarantined event. A denial carries which of §10's four conditions refused it,
which is the one actionable fact about a deferral.

One Result type for every outcome, the same philosophy RequestExecutor#call applies to
requests: a caller branches once instead of rescuing four classes and eventually missing
one. Only SourceBusy and genuinely unexpected errors escape, and the unexpected ones
finalize the run row before re-raising so nothing is abandoned in running — including a
FixtureMiss, which §6 requires to stay loud.

--force is accepted and recorded on the run_started line, and does nothing else in PR 5:
what §9 says it bypasses — the configured cadence and the stored ETag — is PR 6's, and
neither exists to bypass yet. Shipping the flag now means the documented command works and
the CLI surface does not change when PR 6 gives it teeth.

Nothing is written to event_sources beyond provisioning, and no ETag is ever sent. The
corpus still scripts a 304 as its second response and a live 304 can arrive from any cache,
which is why the handling exists before the conditional request does — asserted by a spec
that checks no If-None-Match was sent and event_sources.etag is still nil.

The integration spec runs the real gate, the real ledger and the real URL policy over the
offline corpus, and pins §12's numbers: 8 received, 6 push events seen, 4 created, 3
quarantined under three distinct codes, 1 ignored, three actors and three repositories —
which is also exactly the set of entities the corpus ships enrichment fixtures for. It also
covers the replay (duplicates absorbed, identity refreshed, activity untouched, a planted
skipped_budget entity not reactivated, occurrence counts at two), retries recovering a page
without processing it twice, and each deferral path spending nothing.

One incidental finding, recorded so nobody repeats it: PostgreSQL reads lock_timeout = 0 as
"no timeout", so a zero gate wait blocks forever rather than deferring. The spec uses 0.1s,
as the executor's own spec already does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§9: "Its stdout always proves system state, even when deferred or busy." That rules out
making this a projection of a run result — on the busy path no run happened at all — so it
is a snapshot of what is persisted, from five statements and nothing else.

It never initiates a GitHub request, the same guarantee §11 places on /status, and the
guarantee is structural rather than a promise: the class holds no executor, no transport and
no ledger, and its only collaborators are Active Record models. Two specs pin it. The
fixture transport records every request it is handed and must have none. And the ledger row
count must not change — which also catches the subtler mistake of calling
BudgetLedger#bootstrap! from a read path, since the row is created by the first reservation
and never by a read.

Splitting the snapshot from its rendering is what lets a spec assert §9's "1,284" delimiter
without inserting 1,284 rows, and it is the seam PR 10's /status consumes while the one-shot
consumes #to_s. The rendered block is asserted line for line against §9's sample.

Three budget states, not two, because §16 forbids a misleading guarantee and a fabricated
zero on a fresh install is exactly one: no ledger row at all reads "not yet initialized", a
row whose remaining is NULL reads "unknown (window uninitialized)" — §7 is explicit that 60
remaining must never be assumed — and only a real zero prints as 0.

enrichment_candidates rather than a hand-written pending filter, so both counts are counts
over index_*_on_enrichment_candidates' exact predicate. In PR 5 the two definitions are
numerically identical because nothing transitions an entity until PR 7; §11's finer
pending/skipped split arrives with PR 10.

The rate-limit resource is read from the row rather than hardcoded, so a future
authenticated configuration is a data change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§9's contract, whole: retry the source lock for up to SOURCE_LOCK_WAIT_SECONDS, print
"source busy — poller cycle in progress" plus the state summary and exit 0 when it is
still held, send every request through the same gate and ledger as the poller, and always
prove system state on stdout — "even when deferred or busy".

OneShot returns its exit code and never calls exit; bin/ingest does, and that one line is
the only place in the application that ends a process. Returning the code is what makes the
entire contract a plain unit spec instead of a shelled-out one, including the busy path,
each deferral, and every printed line.

The division of labour is the other half: OneShot owns the human text and the exit code,
IngestionRunner owns the structured logs and every database write. Neither can drift into
the other's stream, which matters because §11 puts both on stdout — so the operator block is
composed and written once, and $stdout.sync is set, and a JSON log line can never split it.

Three exit codes. 0 for ran *or* deferred: §9 pins it for a busy source, and §10's reasoning
extends it to a budget denial, a held gate and a rate-limit response, all of which mean the
request never happened. 1 for an attempt that failed. 2 for refusing to run at all — a bad
option, a configuration the process must not run with, or a corpus gap. §6 requires fixture
mode to fail closed and the executor deliberately re-raises a corpus gap, and "fix the
corpus" is a different outcome from "GitHub failed", so it is not folded into 1.

§9's "Ingestion deferred until T" takes T from effective_poll_time, which is PR 6's. The
instant PR 5 can honestly name is the one the ledger already knows — the window reset — so
the line reads "deferred until <reset> — <reason>", and drops the clause entirely when there
is no instant at all, as for a held gate. Named as a deliberate reading of §9 rather than
left implicit.

--force is parsed and passed through, and does nothing else. Deferring the flag would mean a
reviewer typing the documented command gets a usage error, which is the worst outcome for
§16's reviewer-experience gate; shipping it inert means the command works today, --help says
plainly that cadence gating and ETag reuse land with PR 6, and the CLI surface does not
change when they do.

bin/ingest is a thin shell over config/environment (not config/boot — this command uses
models). It owns exactly one piece of logic, and it has to: the budget configuration is
validated inside to_prepare, so a configuration error raises during the boot require, before
OneShot is loaded and before any logger contract exists. It is matched by class name because
the constant may not be autoloadable at the point boot failed. A spec asserts the file is
executable — a lost exec bit breaks `docker compose run --rm ingest` and nothing else in the
suite would catch it — and that it contains no branching of its own.

The compose service uses an entrypoint with an explicitly empty command, so the plan's bare
`docker compose run --rm ingest` and a flagged `... ingest --force` both work and the image's
puma CMD can never arrive as arguments to bin/ingest. It inherits the shared environment
anchor unmodified, unlike `test`, because the deterministic reviewer path needs
GITHUB_MODE=fixture to reach this container.

spec/docker_compose_spec.rb asserts §16's "plain up starts exactly db, setup and web" gate,
which PR 2 shipped untested and which one mistyped profile key silently breaks. Verified
against Compose itself as well: `config --services` prints three, `--profile tools` prints
five.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
§14 lists this ADR and PR 5 is where the write path it describes lands, so writing it now
rather than later means documenting a decision as it is made instead of after the fact.

It states the alternative that was rejected — chasing exactly-once execution across an HTTP
client, a queue in a second database, and the business tables — names the four mechanisms
that replace it, and is explicit about what the choice costs: repeated non-idempotent side
effects, duplicate work already debited from the budget, events_quarantined counting
observations rather than rows, and a permanent first classification.

The operator consequence is the part worth having written down: re-running
`docker compose run --rm ingest` is safe at any frequency, so nobody has to reason about it
before typing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md requires documentation to stay accurate in the same PR that changes behavior, and
"Nothing polls GitHub yet" stopped being true in this one.

The new One-shot ingestion section carries the four invocations, both literal output blocks,
the exit-code table, the busy contract, and a deterministic fixture walkthrough with the
exact counts the corpus produces on a first run and on a second — the numbers a reviewer can
check against psql in one command. Log expectations are named too: which events appear at
the default level and what LOG_LEVEL=debug adds.

Two README rows had to change rather than being quietly left alone. The one-shot row
promised "--force, deferred/busy semantics" to PR 5; busy and deferred ship here, cadence
gating and ETag reuse do not, so the row is replaced by the section itself plus an explicit
PR 6 row for the half that is missing. The exact-counts row moves from PR 11, since PR 5
documents them now, and PR 11 keeps the full scenario matrix.

The request-path diagram gains IngestionRunner as the holder of the source lock and
PageWriter as the owner of the ingest transaction, so the section no longer stops at the
transport.

.env.example gains three notes and no variables. That the poll allowance is already enforced,
so twelve live one-shot runs an hour is the ceiling at the defaults and the thirteenth is
deferred rather than failed. That the fixture cursor lives on the transport instance, which
is what makes duplicate absorption demonstrable by running the command twice. And
GITHUB_EVENT_TYPES joins the "deliberately absent" list with its reasoning: §6 offers it as
an option, one processor ships, so its only legal value is its default —
ProcessorRegistry.for already performs the validation §6 requires.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A missing `require "optparse"` survived all 758 examples and died on the first real
invocation: rspec loads optparse through another gem, so the constant resolves in the suite
and nowhere else. Rails does not require it.

The fix is one line; the interesting part is that no unit test could have caught it. The
suite boots Rails through rails_helper, so the one-shot's own boot path — its shebang, its
config/environment require, and every stdlib it depends on but the harness happens to have
loaded already — is unexercised by construction. Running the command once is the only thing
that exercises it, so both bin/ci and the GitHub Actions workflow now do, in fixture mode so
it touches no network and against the already-prepared test database.

Verified by hand against the built image after the fix, over the full outcome matrix:

  live       100 events seen, 98 push events created, 2 non-push ignored, exit 0
  fixture    4 created / 3 quarantined / 1 ignored, then 0 created / 4 duplicates on a
             second run, with psql reporting 4 push events, 3 actors, 3 repositories and
             3 quarantine rows at occurrence_count 2 — the numbers the README documents
  busy       source lock held by a second PostgreSQL session: "source busy — poller cycle
             in progress" plus the state summary, exit 0, no ingestion_runs row, and the
             ledger byte-identical before and after, which is the receipt proving no
             request was issued
  deferred   "Ingestion deferred until 2026-07-30T15:24:13Z — class_allowance_exhausted",
             exit 0, run recorded as deferred with no last_error
  usage      --help exits 0; --turbo and a stray positional exit 2 on stderr

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The deferred headline queried github_api_budget directly for reset_at, duplicating a value
StateSummary captures a moment later and re-stating knowledge the summary owns. The snapshot
is now taken first and handed to the headline builder, so both come from one read and the
command asks the database nothing directly — which is the property that keeps "the state
summary never initiates a GitHub request" a structural guarantee rather than a habit.

No behaviour change; re-verified against the built image that the line still reads
"Ingestion deferred until 2026-07-30T15:29:20Z — class_allowance_exhausted".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@batbrainy batbrainy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few things to tighten before I’d approve this.

def ensure!(mode: Github.configuration.mode, now: Time.current)
source_type = EventSources::Base.for_mode(mode).source_type

existing(source_type) || create(source_type, now) || existing(source_type)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This still returns the row this process just created. Because source_type is not unique, two first-time one-shots can both miss existing, insert separate rows, and then take source locks on different event_source.ids. The comment below says the race converges on the lowest id, but that only happens for later callers. I’d re-select existing(source_type) after any create, or provision under a stable source-type lock, so the source-lock contract holds on a fresh database.

# the ledger already knows: the window reset. When there is none — a held gate has no
# instant at all — the reason stands on its own.
def deferred_line(result, summary)
reset_at = Report.timestamp(summary.budget_reset_at)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This uses the ledger reset timestamp for every deferred result. That is accurate for a reset-backed budget denial, but a gate_unavailable or secondary_limited result can inherit an unrelated reset from the state summary and print a misleading deferred until. I’d only include until when the result has a real deferral instant or the reason is explicitly reset-backed; otherwise just print the reason.

Comment thread app/services/github/ingestion_runner.rb Outdated
def deferral(fetched)
reason = fetched.deferred? ? deferral_reason(fetched) : fetched.classification.to_s

Rails.logger.info(event: "ingestion.deferred", reason: reason,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This standalone deferral log line drops the run_id, while the rest of the ingestion flow is correlated by it and the README says every run log line carries it. Could pass recorder.run_id into deferral and include it here, or let ingestion.run_completed be the single correlated deferral record.

…related log line

Three review findings, all real.

**The provisioning race was not harmless.** ensure! returned the row *this* process had just
created, so on a fresh database two first-time one-shots could each end up on their own row,
take source locks on two different event_source.id values, and poll the same feed
concurrently — precisely the guarantee §9 asks the source lock to provide. The
"converges on the lowest id" comment was only true for later callers, and the
rescue ActiveRecord::RecordNotUnique it sat beside was dead code, since there is no unique
constraint to violate.

Fixed by serializing the check and the insert with
LOCK TABLE event_sources IN SHARE ROW EXCLUSIVE MODE inside the provisioning transaction. It
self-conflicts so two provisioners queue, does not conflict with SELECT so no reader waits,
and releases at COMMIT so nothing can leak it. ensure! still reads first and outside any
transaction, so the lock is a once-per-database cost and every later call is a single SELECT
— asserted. Deliberately not a fourth advisory-lock namespace: this is one table's
first-write problem, and Github::LockOrder exists to police the two locks that can actually
deadlock against each other. The lowest-id selection stays, because duplicate rows can
predate this code or be created by hand and every process still has to agree on one key.

**The deferral headline borrowed an unrelated instant.** Every deferred result took the
ledger's reset_at, so a held gate or a secondary limit printed "deferred until <reset>" —
confidently wrong, which is worse than saying nothing. The clause is now limited to the two
denial reasons that reset actually governs (class_allowance_exhausted, reserve_reached); a
held gate has no instant at all, and a secondary limit clears on a Retry-After that PR 5 does
not persist. Everything else states the reason alone.

**ingestion.deferred dropped run_id**, making it the one line in the flow that could not be
joined to its run — and it is exactly the line an operator greps for when a run produced no
events. Threaded through.

Verified against the built image: a fresh database provisions exactly one row at id 1; a
budget denial still reads "Ingestion deferred until 2026-07-30T16:47:59Z —
class_allowance_exhausted"; and with the request gate held by a second PostgreSQL session the
one-shot prints "Ingestion deferred — gate_unavailable" with no instant, exits 0, and logs
ingestion.deferred carrying the same run_id as ingestion.run_completed.

764 examples, 0 failures. RuboCop clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@batbrainy
batbrainy merged commit a934b00 into main Jul 30, 2026
2 checks passed
@batbrainy
batbrainy deleted the pr-5-push-event-ingestion branch July 30, 2026 16:03
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.

PR 5 — Push-event ingestion

1 participant