Skip to content

feat(firehose): self-test wedge defence + alive sentinel + decode-error robustness - #42

Merged
cloakmaster merged 2 commits into
mainfrom
day-8/firehose-observability
May 3, 2026
Merged

feat(firehose): self-test wedge defence + alive sentinel + decode-error robustness#42
cloakmaster merged 2 commits into
mainfrom
day-8/firehose-observability

Conversation

@cloakmaster

Copy link
Copy Markdown
Owner

Why (Day-7 → Day-8 follow-up)

Two firehose observability findings surfaced during the Day-7 verification gate (PR #40's bench artifact + Day-7 close memory):

  1. postgres-js LISTEN dispatch can wedge on a synchronous JSON parse failure in the user callback. We observed this live: a single malformed pg_notify payload caused the listener to receive exactly ONE event then silently drop every subsequent NOTIFY despite the TCP connection staying alive. Restart cleared it. Production triggers always emit valid row_to_json JSON so this isn't a current runtime risk — but a future migration or event type would silently kill the firehose with no automatic recovery.
  2. Reconnect was silent (postgres-js library-level), not loud (our wrapper-level). When we kill the listener PID via pg_terminate_backend, postgres-js handled the LISTEN reconnect transparently and our wrapper's catch block never fired — events resumed without any firehose_listener_reconnect attempt=N sentinel reaching operator logs.

This PR adds defence-in-depth for both, scoped narrowly per the Day-8 directive.

What

1. Self-test wedge defence (Finding #1)

If selfTestIntervalMs is set (default 60s, 0 disables), the listener periodically fires its own pg_notify carrying a __listener_self_test__ event_type + fresh nonce. The listener's own onnotify handler:

  • recognizes the event_type,
  • filters it from the public EventEmitter (SSE clients never see it),
  • updates lastSelfTestEchoNonce.

On the next heartbeat tick, if the prior nonce never echoed within selfTestIntervalMs * 1.5 tolerance, the listener throws → forces the reconnect-backoff path → resubscribes. New sentinel:

firehose_listener_self_test_failed observed_lag=Nms expected_within=Mms

This catches the wedge regardless of what postgres-js's library-level state is doing — we don't need to monkey-patch the library, we just verify our own dispatch is healthy from the inside.

2. Periodic alive sentinel (Finding #2 prep)

firehose_listener_alive heartbeats=N events=M self_tests_ok=K

Fired every aliveLogEveryHeartbeats (default 10, 0 disables) → ~5 min at default heartbeat. Gives operators a positive-confirmation signal without grepping for the absence of reconnect logs. Counters are per-cycle (reset on resubscribe).

3. Decode-error robustness (documentation only)

The existing JSON-parse catch + sentinel log already worked. Adding a comment explaining that received bytes prove dispatch is healthy at the postgres-js layer — the wedge wasn't from our parse failure but from postgres-js internal state. No behaviour change.

What's NOT in this PR

  • postgres-js onnotice callback wiring. The library exposes onnotice as a construction-time option on postgres(); threading it through createDirectPostgresClient would need a small factory-options extension. Doable, but onnotice only fires for Postgres NOTICE-level messages (e.g. RAISE NOTICE), not connection-state transitions — which is what Finding ADR 0001: service-agnostic core (accepted, verified) #2 was actually asking for. The self-test mechanism above is the load-bearing defence; onnotice would be a small additional visibility layer. Filed as a follow-up if NOTICE-level visibility becomes operationally necessary.
  • A workflow / cron to alert on firehose_listener_alive absence. That's an operator-dashboard concern; the sentinel logs are the building block.

Tests

3 new cases in apps/api/__tests__/firehose.listener.test.ts (existing 67 tests still pass; 70 total in apps/api):

  • Self-test fires + echoes — heartbeat tick triggers self-test ping; injected echo via mock.notify is filtered from public emitter; subsequent real event passes through normally.
  • Self-test wedge — ping fires but echo never arrives; tolerance window elapses; firehose_listener_self_test_failed logged; reconnect-backoff fires; factory invoked a second time (resubscribe). Validates the full wedge → reconnect → recovery path.
  • aliveLogEveryHeartbeats=0 disables alive sentinel — clean-log surface for low-noise environments.

Verification

  • pnpm check:core-isolation
  • pnpm check:generated (8 files match)
  • pnpm -r typecheck
  • pnpm --filter @foxbook/api test → 70 passed + 4 skipped
  • CI green
  • Drafted for review — opening as draft per directive ("DO NOT merge yet — open as PR for my review").

Refs

…or robustness

Day-8 housekeeping for the two firehose observability findings flagged
in PR #40's bench artifact (and the Day-7 close memory):

1. **postgres-js LISTEN dispatch wedge on bad-JSON callback (Finding #1).**
   On Day-7 the listener received exactly ONE notification with a malformed
   (non-JSON) payload, the decode_error logged, and every subsequent NOTIFY
   (valid or not) was silently dropped despite the underlying TCP connection
   staying alive. Verified at the time via pg_stat_activity: connection idle,
   application_name still 'foxbook-firehose-listener', dispatch wedged at
   the postgres-js library layer. Production triggers always emit valid
   row_to_json JSON, so this isn't a runtime risk today — but a future
   migration or future event type that emits something the listener can't
   parse would silently kill the firehose with no automatic recovery.

   **Defence:** opt-in self-test heartbeat. If `selfTestIntervalMs` is set
   (default 60s; 0 disables), the listener periodically fires its own
   `pg_notify` carrying a `__listener_self_test__` event_type with a fresh
   nonce. The listener's own onnotify handler recognizes the event_type,
   filters it from the public EventEmitter (SSE clients never see it), and
   updates `lastSelfTestEchoNonce`. On the next heartbeat tick, if the prior
   ping's nonce never echoed within `selfTestIntervalMs * 1.5` tolerance,
   the listener throws → forces the reconnect-backoff path → resubscribes.
   Sentinel log:

       firehose_listener_self_test_failed observed_lag=Nms expected_within=Mms

   Tested with 3 new cases:
   - Self-test ping fires + echoes cleanly → no reconnect, public emitter
     never sees the self-test event.
   - Self-test ping fires but never echoes → tolerance window elapses →
     `firehose_listener_self_test_failed` + reconnect + resubscribe (factory
     invoked twice).
   - aliveLogEveryHeartbeats=0 disables the periodic alive sentinel
     (clean-log surface for low-noise environments).

2. **Decode-error robustness.** The decode-error path already caught
   `JSON.parse` failures + sentinel-logged. Adding the comment that bytes
   were received successfully (dispatch is healthy at the postgres-js
   layer) so future-readers understand the wedge isn't from our parse
   failure — it was from postgres-js's internal state. No behaviour change;
   documentation only.

3. **Periodic alive sentinel (Finding #2 prep).** New
   `firehose_listener_alive heartbeats=N events=M self_tests_ok=K` log
   line, fired every `aliveLogEveryHeartbeats` heartbeats (default 10 →
   ~5 min at default heartbeat). Gives operators a positive-confirmation
   signal that the listener is live + how many events have flowed without
   needing to grep for the absence of reconnect logs. Counters reset on
   each (re)subscribe so the value is per-cycle, not lifetime.

NOT included today (filed for follow-up if NOTICE-level visibility becomes
load-bearing): postgres-js `onnotice` callback wiring. The library exposes
this as a construction-time option on `postgres()`; threading it through
`createDirectPostgresClient` would require a small factory-options
extension. Doable, but `onnotice` only fires for Postgres NOTICE-level
messages (e.g. raised by RAISE NOTICE), not connection-state transitions —
which is what Finding #2 was actually asking for. The right defence for
"silent library-level reconnect" is the self-test mechanism above (it
catches the wedge regardless of what postgres-js's library-level
state is doing); the `onnotice` debug callback would be a small additional
visibility layer rather than a load-bearing fix.

Verification (all green):
  * pnpm check:core-isolation
  * pnpm -r typecheck
  * pnpm --filter @foxbook/api test → 70 passed (3 new self-test cases)

Implements the postgres-js LISTEN wedge defence + alive sentinel from
the Day-7 close memory's "Day-8 queue".
@cloakmaster
cloakmaster marked this pull request as ready for review May 3, 2026 06:39
@cloakmaster
cloakmaster merged commit 94f04c0 into main May 3, 2026
3 checks passed
@cloakmaster
cloakmaster deleted the day-8/firehose-observability branch May 3, 2026 06:41
cloakmaster added a commit that referenced this pull request May 3, 2026
* docs: stable-mode Phase 2 — documentation lockdown

Stable-mode wrap per ADR 0008 (this PR introduces it). Phase 2 of the
plan at /Users/tester/.claude/plans/focus-deeply-and-use-nifty-swan.md.
No code changes; no behavior changes; protocol surface unchanged.

## What's in this PR

- ADR 0006 (RETROACTIVE, dated 2026-04-26) — protocol-not-marketplace
  path-ordering rule + co-option-not-forking failure mode. Backdated
  to original ratification per the 2026-04-26 external review.
  EXPLICITLY does NOT include the SR 11-7 / EU AI Act / cyber-insurance
  monetization wedge that was discussed during the same review but
  REJECTED four days later (2026-04-30); a doc dated 2026-04-26 must
  not retrofit later-rejected framings.
- ADR 0008 (NEW, dated 2026-05-03) — stable mode and maintenance
  posture. Feature freeze, substrate stays live, brand protection
  persists, future direction = TBD pending external integration signal.
- README.md Status section — replaces "in progress" stragglers (SDK
  npm publish + MCP server) with stable-mode framing pointing at
  ADR 0008. Adds harness aggregator entry callout.
- TRADEMARK.md — removes "currently DNS-only; landing-page content
  lands week-2" outdated note (Phase 5 lands the landing page).
- CONTRIBUTING.md (NEW) — what's welcome (bug fixes, docs, forks
  under different names) vs not happening (new features, spec
  extensions). Security: security@foxbook.dev.
- NOTICE (NEW) — Apache 2.0 attribution + canonical impl pointer +
  cross-impl reference status.
- docs/RATIONALE.md (NEW) — narrative summary of all 8 ADRs.
  Audience: stranger discovering Foxbook in 12 months.
- docs/VERIFY-IN-60-SECONDS.md (NEW) — written walkthrough of the
  live demo, three curls + SDK reconstruction.

## Review gate

ADR 0006 is the strategic doc in this PR; the protocol-not-marketplace
thesis is historically scoped (what was ratified WHEN). Other docs are
factual / structural and flow without per-doc review.

Specific scope discipline on ADR 0006:
- INCLUDED: protocol-now leaves marketplace-later open (path asymmetry);
  marketplace-now closes protocol-ever; co-option-not-forking failure
  mode; defense indicators are cross-impl references that name the
  canonical impl.
- EXPLICITLY NOT INCLUDED: SR 11-7 / EU AI Act Article 12 / cyber-
  insurance monetization wedge (rejected 2026-04-30, four days after
  ratification). A future ADR can revisit monetization framing under
  its own number with its own date.

## Verification

- pnpm check:generated — 10 files match
- pnpm check:core-isolation — clean
- pnpm -r typecheck — clean
- No code touched, so no test runs needed

## Refs

- Plan: /Users/tester/.claude/plans/focus-deeply-and-use-nifty-swan.md
- ADR 0006 sources: external review 2026-04-26 (memory
  project_protocol_thesis_external_validation.md captures the full
  discussion; ADR 0006 captures only what was ratified)
- ADR 0008 sources: stable-mode plan + Phase 1 close (PRs #42, #60, #61)

* docs(adr-0006): minor edits per review (verified-date header + drop 'Wedge 1' tag)

---------

Co-authored-by: Ben <ben@inkog.io>
cloakmaster added a commit that referenced this pull request May 4, 2026
…ng (#64)

Phase 4 of the stable-mode plan. Master runbook for keeping
transparency.foxbook.dev / api.foxbook.dev / foxbook.dev running
unattended at low cost. No code changes; no behavior changes.

## What's in this PR

- docs/OPERATIONS.md (NEW) — master runbook covering:
  - Future-you sanity check (5 commands to run when something breaks)
  - Monthly cost summary (target <$30/mo)
  - Re-deploy each service (Fly.io api, Cloudflare Worker, Pages landing)
  - Key rotation (transparency-log signing key + per-agent keys)
  - Incident response (log corruption, forged claim, leaked secret)
  - Database backups (Neon PITR by tier)
  - Uptime monitoring (this PR's workflow + silencing alerts)
  - Phase 5 stable-mode landing notes
  - Cross-references to ADRs + config files
- .github/workflows/uptime.yml (NEW) — 15-min cron, three endpoints,
  opens / comments-on / closes GitHub issues tagged `uptime-incident`.
  All matrix values passed via env: rather than direct ${{ }}
  interpolation (defense-in-depth against future user-derived matrix
  entries; matrix is static config today).
- apps/api/fly.toml — added cross-reference comment pointing at
  OPERATIONS.md for re-deploy / rollback / key-rotation procedures.
  Existing 72-line first-time-setup runbook preserved.
- docs/operations/env-vars.md — rewritten to reflect production
  reality (Fly + Cloudflare Worker secrets, four actual variables).
  Removed Vercel / Upstash / KMS / MCP sections that were earlier
  scaffolding never shipped. Added pointer to OPERATIONS.md for
  the broader runbook.

## Verification

- pnpm check:generated — 10 files match
- pnpm check:core-isolation — clean
- pnpm -r typecheck — clean
- No code touched (docs + workflow only)

## What's NOT in this PR

- Cloudflare Pages landing page itself (foxbook.dev) — Phase 5
- Final Discussion #1803 announcement — Phase 5, deferred until
  Phase 4.5 (eriknewton spec session + ADR 0009) lands first

## Refs

- Plan: /Users/tester/.claude/plans/focus-deeply-and-use-nifty-swan.md
- ADR 0008 — stable-mode posture (Phase 2)
- Phase 1: shipped 2026-05-03 (#42, #60, #61)
- Phase 2: docs lockdown (#62, #63)
- Phase 3: brand protection (NOTICE + TRADEMARK in #62; identity-bound
  terminal steps confirmed by maintainer)
- Phase 4: this PR
- Phase 4.5: ADR 0009 after eriknewton spec session (gates Phase 5)
- Phase 5: deferred until 4.5 lands

Co-authored-by: Ben <ben@inkog.io>
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