Skip to content

fix: post-11.07 audit — 8 findings (webhook keyset cursor, helm PG egress/PDB/selector, control-plane clock & retry, E4 settle window)#200

Merged
brownjuly2003-code merged 11 commits into
mainfrom
fix/audit-post11
Jul 17, 2026
Merged

fix: post-11.07 audit — 8 findings (webhook keyset cursor, helm PG egress/PDB/selector, control-plane clock & retry, E4 settle window)#200
brownjuly2003-code merged 11 commits into
mainfrom
fix/audit-post11

Conversation

@brownjuly2003-code

@brownjuly2003-code brownjuly2003-code commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Summary

Lands all 8 findings from the post-11.07 adversarial audit (2 latent P1, 4 P2, 1 low, 1 docs), plus everything the landing itself surfaced: a main-inherited red format gate, a main-inherited PG-probe contract drift, and a P1 in the keyset fix found by pre-merge adversarial review.

# Sev Fix Commit
1 P1 Webhook journal scan: composite keyset cursor — a saturated second can no longer wedge the scan 23106ff
5 P1 Helm: NetworkPolicy egress to PostgreSQL 5432 when store=postgres + networkPolicy.enabled c3a6fcb
6 P2 Helm: PDB scoped to api pods only c3a6fcb
7 P2 Helm: unconditional component: api selector (one-time breaking upgrade; migration doc) c3a6fcb
2 P2 Control plane: next_attempt_at computed on the DB clock 63df889
3 P2 E4 replica checks: settle window instead of break-on-first-sighting 640c117
4 P2 Control plane: transient-error retry on record_webhook_delivery_outcome 0709e91
8 low Dependabot: ignore also gates security-update PRs (docs) a59c814

Landing follow-ups:

  • 5252c3d — ruff format on 3 test files (the same files have kept main's lint red since 4a5d524, masking test-integration there).
  • c6a81fa — 5 PG claim probes aligned with main's 0669dd5 enqueue-lease contract (38/38 on live PG 17.10) + new live-ClickHouse keyset regression (saturated second, exactly-once, on real CH 25.3 — also runs in CI's test-integration).
  • 983b9acsettle watermark (pre-merge adversarial review found a P1 in the keyset fix itself: a strict cursor in the still-open second silently drops late-visible same-second rows with lower UUIDs — the old inclusive scan was lossless below scan_batch_size). Every dispatcher fetch is now bounded to DB-clock-settled seconds (AGENTFLOW_WEBHOOK_SETTLE_SECONDS, default 3 s); startup seeding likewise. Operator invariant documented: settle > writer stamp-to-visibility lag + clock skew.
  • 7d58215 — re-review follow-ups: settle=0 is a true opt-out; the new regressions bind aware datetime.now(UTC) exactly like production journal writes, pinning the frame equivalence.

Verification

  • CI fully green (5 rounds; includes the live-CH keyset suite and the repaired PG live suite).
  • Live (Mac stand): CH 25.3 keyset+watermark 4/4 · existing live CH suites 48/48 · PG 17.10 control-plane 38/38 · full unit suite 1984 passed.
  • Audit Chaos scheduled run failed — 2026-05-04 #5 proven live on an enforcing CNI (kind + Calico v3.28.2, external PG): no policy → TCP_OK; main's policy → BLOCKED (the gap was real); this branch's policy → TCP_OK.
  • Known-accepted residuals (documented in serving-bridge.md / commit messages): no runtime detector for a violated settle invariant; record_webhook_delivery_outcome retry can double-increment attempts if the connection drops exactly at commit-ack (narrow; premature dead-letter only).

🤖 Generated with Claude Code

JuliaEdom and others added 7 commits July 17, 2026 21:23
…ghting

Checks 3 and 4 broke the poll loop the instant one delivery/page appeared and
then asserted count == 1, so a split delivery/page from the other pod landing a
poll cycle (or one 60s alert tick) later passed undetected — a boundary the
assertion could not distinguish from its absence (audit 2026-07-17 #3).

Now: wait up to WAIT_SECONDS for the first emission, then dwell a settle window
(DELIVERY_SETTLE_SECONDS=12; ALERT_SETTLE_SECONDS=75 > one 60s tick) and fail the
moment a second delivery_id/page appears; assert == 1 after the dwell. bash -n
clean; static contract test green. Not live-run (needs the scale kind stand).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…app clock

record_webhook_delivery_outcome and schedule_outbox_retry wrote
next_attempt_at with the application pod's datetime.now(UTC), but
claim_due_webhook_deliveries / claim_due_outbox_entries compare it against
Postgres's now(). Under NTP skew between app and DB hosts, this collapses
the exponential backoff (app clock behind DB -> row immediately due -> retry
burst) or over-delays it (app clock ahead).

Mirror the lease-column pattern already used for lease_expires_at: compute
the timestamp in SQL as now() + make_interval(secs => %s), passing the
delay in seconds as a query parameter instead of a Python datetime. The
NULL next_attempt_at cases (dead/failed rows) are unchanged in effect --
schedule_outbox_retry's NULL branch now flows through a SQL CASE keyed on
the same retry_count >= max_retries condition instead of being computed in
Python.

The embedded (DuckDB) adapter is untouched: it is app-clock on both write
and compare, which is internally consistent and not part of this bug.

Audit finding #2 (2026-07-17 post-11 review).
…#8)

A Dependabot `ignore` applies to both version and security updates — there is
no per-ignore `applies-to` (that key is groups-only). The semver-major ignores
on langchain-core/-text-splitters/langsmith/dagster therefore also suppress an
auto security-update PR for a CVE fixed only in a major bump. That is an
accepted tradeoff (their majors are gated deliberately) and not silent: the
safety + pip-audit jobs still fail on such a CVE. Comment the tradeoff so it is
a conscious decision, not a latent gap. No behavior change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t DB errors

If a webhook POST succeeds but record_webhook_delivery_outcome then hits a
momentary DB blip (lost connection, exhausted pool checkout), the enqueue
lease this call would have cleared is never cleared: the row stays
pending+leased for the full claim_lease_seconds window (~300s) instead of a
fast redrive, turning a ~2s recovery into a ~5min stall.

Mirror record_api_usage's existing bounded-retry loop exactly: up to 3
attempts, catch _TRANSIENT_ERRORS (OperationalError, PoolTimeout), sleep
0.01 * (attempt + 1) between attempts, raise the last error if all attempts
are exhausted. The mechanism is inline in record_api_usage (duplicated
again in record_api_usage_batch, not a shared helper), so this is a third
faithful copy rather than an invented pattern or a forced extraction.

The retry wraps the whole method (fresh self._connect() per attempt) so all
three outcome branches -- delivered, dead, pending-with-backoff -- get the
retry, since a transient error on any of them would otherwise strand the
lease. Each successful branch now returns explicitly from inside the try,
which matters now that a bare fall-through would loop back for a spurious
extra attempt. SQL text and parameters are unchanged from the finding-#2
commit; only re-wrapped for the new indentation.

Does not change at-least-once semantics, lease duration, or embedded.py
(the DuckDB adapter has no pool/network transient-error class to retry).

Audit finding #4 (2026-07-17 post-11 review).
…audit #5 #6 #7)

#5 NetworkPolicy: add a PostgreSQL :5432 egress rule (gated on
controlPlane.store=postgres) + egressPorts.postgres. Without it, enabling
networkPolicy on the postgres profile denies every pod's PG connect and the
control-plane pool never opens.

#6 PodDisruptionBudget: scope the selector to component=api so it guarantees a
request-serving pod survives a drain; the component-less selector also matched
worker pods, so one minAvailable:1 budget could let both API pods be evicted.
Workers get no PDB by design (a single-replica worker PDB makes its node
undrainable; the delivery loop resumes after reschedule from PG-claimed rows).

#7 immutable selector: make app.kubernetes.io/component=api UNCONDITIONAL on the
API Deployment (labels, spec.selector, pod template) instead of gating it on
worker.enabled, so the immutable selector no longer changes when the split is
toggled. The one-time migration for pre-existing worker.enabled=false installs
(selector change needs a Deployment recreate) is documented in NOTES.txt and
docs/migration/helm-component-selector.md; no pre-upgrade hook (it would fire on
every upgrade, turning a one-time step into a recurring outage).

Subagent-authored, reviewed and integrated by the main loop. Not live-verified
against a cluster (no kind stand on this box).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ge the journal scan

Audit 2026-07-17 finding #1 (latent P1). The webhook journal-scan cursor was
second-granular: dispatch fetched `WHERE processed_at >= cursor ORDER BY
processed_at, event_id ASC LIMIT scan_batch_size` and only ever advanced
`_scan_cursor` to the whole second of the last handled row. If one second held
>= scan_batch_size rows, every batch filled with that second's lowest-event_id
rows, the cursor re-pinned to the SAME second, and every webhook for every
tenant at/after that second was silently, permanently undelivered — with a
healthy-looking cursor and no error. The comment stated the required invariant
(batch > largest same-second cohort) but nothing enforced it, and no test
covered it.

Fix: make the scan cursor a composite (processed_at, event_id) keyset so it
advances WITHIN a saturated second.

- engine.fetch_pipeline_events: add `min_event_id`; when paired with
  `min_processed_at`, emit the keyset predicate
  `t > CAST(:ts) OR (t = CAST(:ts) AND event_id > :id)` (portable
  OR-decomposition — the row-value tuple form does not transpile to
  ClickHouse). Sub-second precision is preserved on this path so a saturated
  DuckDB second stays discriminable; ClickHouse processed_at is second-granular
  so the literal is a whole second there. `min_event_id`=None keeps the prior
  inclusive `>=` bound (search_index caller unchanged).
- webhook_dispatcher: `_scan_cursor` becomes (processed_at, event_id) of the
  last CONTIGUOUS handled row; freeze-on-first-unseen/enqueue-failed retry
  semantics and the newest-row startup seeding are preserved. The seen-set is
  now a secondary safety net, not the primary dedup.

Verified: OR-form transpiles to valid ClickHouse (parse duckdb -> generate
clickhouse -> re-parse, table refs preserved) and drives a 2500-row saturated
second to completion on DuckDB. New regression test seeds one second holding
more than scan_batch_size rows and asserts every event is delivered and the
cursor passes the second; it FAILS on the pre-fix code (confirmed by revert)
and passes here. Live ClickHouse/Postgres not run in this environment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CHANGELOG [Unreleased]: one section for the 8 audit fixes (2 latent P1, 4 P2,
1 low), pointing at audit_2026-07-17_post11.md.

serving-bridge #183 section: the webhook journal-scan cursor is now the
composite (processed_at, event_id) keyset (strictly-after), not a
second-granular inclusive high-water mark — documents why it advances within a
saturated second and no longer wedges (audit #1).

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

Copy link
Copy Markdown

DORA Metrics

  • Window: last 30 days
  • Branch: main
  • Deployment frequency: 100 total / 23.33 per week
  • Lead time for changes: avg 0.37h / median 0.0h
  • Change failure rate: 75.0% (75/100)
  • MTTR: 2.56h across 5 incident(s)

MTTR note: 4 failed run(s) are still unresolved in the selected window.

JuliaEdom and others added 4 commits July 18, 2026 01:04
CI's separate `ruff format --check` gate flagged them on PR #200; check
and format are distinct gates and only the former was clean. 27 tests in
the three files still pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gression

Two live-suite pieces, both verified against real servers via the Mac stand
(ClickHouse 25.3, PostgreSQL 17.10 in Colima, SSH tunnels).

1) test_control_plane_postgres_live.py — 5 claim-path probes still encoded
   the pre-0669dd5 contract (fresh enqueue immediately claimable). Since
   0669dd5 the enqueue winner stamps a claim lease on insert (it
   inline-delivers), so those probes got [] and failed. Main CI never caught
   it: the same session left three unformatted test files, lint failed, and
   test-integration was skipped on every push since — this branch's format
   fix un-masked the failures in PR #200 CI. Add _release_enqueue_lease()
   (hands rows to the claim path the way lease expiry would) and call it in
   the five probes; the probed properties — claim exclusivity, claim-lease
   invisibility, expiry redrive, restart survival, oldest-first — are
   unchanged. Before: 5 failed / 33 passed; after: 38 passed live.

2) test_webhook_keyset_clickhouse_live.py — the audit #1 residual: keyset
   was proven on DuckDB + transpile-shape only, never against a live
   ClickHouse. Three probes on a real server (own database, dropped after):
   the inclusive >= bound demonstrably cannot pass a saturated second (the
   wedge premise), the OR-decomposition keyset paginates through it, and the
   full dispatcher loop drains a 12-row second with scan_batch_size=5 —
   every event delivered exactly once, cursor past the second. 3 passed
   live; existing live CH suites still green (48 passed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sed seconds

Adversarial review of the keyset fix (23106ff) found a P1 regression the
strict cursor introduced into the ORDINARY load regime: ClickHouse
processed_at is second-granular and event ids are UUIDs (not monotonic), so
a cursor advanced into the still-open wall-clock second permanently excludes
any same-second row that becomes visible later with a lower event_id. The
old inclusive scan was lossless below scan_batch_size (it re-fetched the
cursor second every poll; the seen-set deduped) and only wedged at >= 1000
rows/sec; the strict keyset never wedges but silently dropped late
same-second arrivals at any load. The seen-set cannot save a dropped key:
it prevents re-delivery, never a re-fetch below the frontier.

Fix: every dispatcher journal fetch is bounded by a DB-clock settle
watermark — processed_at <= CAST(now() AS TIMESTAMP) - INTERVAL 'N' SECOND
(AGENTFLOW_WEBHOOK_SETTLE_SECONDS, default 3; 0 opts out, tests only) — so
the frontier only crosses seconds no writer will stamp again. Both frames
are self-consistent per backend (verified empirically: DuckDB stores
tz-aware stamps in session-local wall time and CAST(now() AS TIMESTAMP)
yields the same frame; ClickHouse stores and compares in server time).
Startup seeding is bounded the same way: unsettled rows are deliberately
not marked seen — they deliver once settled, deduped by the idempotent
durable enqueue, so an event racing a restart is not lost. Re-fetching
callers (search refresh, SSE, metric cache) keep the unbounded scan.
Invariant the operator owns: settle > writer stamp-to-visibility lag +
writer-DB clock skew.

Tests: new unit regression drives the exact drop scenario (late lower-id
same-second arrival behind an advanced frontier) — held back, then
delivered exactly once, never lost; seeding pins settled-only; SQL-shape +
transpile pins for the watermark clause; live-CH probe asserts a freshly
stamped row is invisible to a settled fetch and visible to an unbounded one
(passed against real ClickHouse 25.3). Fixture rows stamped NOW()/-1..3s in
existing suites moved behind the watermark or opt out with settle=0 where
the probe is mechanics, not freshness. Full unit suite: 1984 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ame in tests

Re-review follow-ups on 983b9ac (no P0-P2 found; three P3 + two nits):

- settle_seconds=0 now emits NO watermark clause at all. It previously
  rendered 'processed_at <= now() - INTERVAL 0 SECOND', which still
  withholds future-stamped rows under forward writer skew — not what the
  documented opt-out means. Pinned by a new SQL-shape test.
- The two new dispatcher regressions now stamp journal rows by binding
  AWARE datetime.now(UTC) — exactly the production DuckDB write path —
  instead of pre-rendered naive-local strings, so the frame equivalence the
  watermark relies on (aware param and CAST(now() AS TIMESTAMP) both land
  session-local) is pinned by a committed test, not just by the empirical
  note in the previous commit message.
- serving-bridge doc: the settle invariant is an operating requirement (a
  violation is silent); non-UTC DuckDB hosts see delayed (never dropped)
  delivery across the autumn DST fold.

Known-accepted residuals (documented, not fixed): no runtime detector for a
violated settle invariant; record_webhook_delivery_outcome retry can
double-increment attempts if the connection drops exactly at commit-ack
(narrow, premature dead-letter only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@brownjuly2003-code
brownjuly2003-code merged commit 05afb32 into main Jul 17, 2026
26 checks passed
@brownjuly2003-code
brownjuly2003-code deleted the fix/audit-post11 branch July 17, 2026 23:48
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.

2 participants