Skip to content

feat: /polls plane — stateful polling jobs (WebFetchPollJob), scheduler, event forwarder - #6

Merged
shadowbrush merged 5 commits into
mainfrom
feat/polls-plane
Jul 14, 2026
Merged

feat: /polls plane — stateful polling jobs (WebFetchPollJob), scheduler, event forwarder#6
shadowbrush merged 5 commits into
mainfrom
feat/polls-plane

Conversation

@shadowbrush

Copy link
Copy Markdown
Member

P2 of #4: the stateful polling plane, implementing specs cor:web:030:00..03. The /ops plane is untouched and stays per-call stateless (cor:web:000 as rescoped).

What's in

Store seamPollStore interface with two implementations: PrismaPollStore (Postgres, production) and InMemoryPollStore (test suite + DB-less dev), keeping the repo's no-external-deps-in-tests convention. Claiming is lease-based and atomic (UPDATE … WHERE id IN (SELECT … FOR UPDATE SKIP LOCKED) RETURNING) so a second replica is safe by construction. Model is WebFetchPollJob (platform-ordered naming).

Credential at rest (cor:web:030:02) — AES-256-GCM under TOKEN_ENCRYPTION_KEY with a key-id fingerprint for rotation detection; encrypted at creation, decrypted ONLY in the tick path, never on any read surface, disposed on cancel/expiry/failure/done.

/polls routes — create/get/list/cancel + POST /polls/:id/run (forced tick on the scheduler code path, for e2e). Creation fails fast: interval floor, mandatory TTL ≤ cap, per-org active cap, condition validation, the ops-plane header/auth policy, and the full URL guard runs at creation so a forbidden target never becomes a job. No authorization here — orgId/appId are core-trusted (invariant).

Scheduler tick (cor:web:030:01/:03) — expiry-first (terminal events retried until core accepts; a watch never silently ends), per-tick full guard re-run (nothing trusted across ticks), first-observation baseline init, fire policies once/every_change/cooldown with edge-triggered re-fire, advance-only-after-delivery (baseline/lastNotifiedHash move only on 2xx from core; at-least-once, core dedupes on jobId+snapshotHash+kind), exponential backoff with terminal poll.failed at the limit (best-effort event — core was down the whole run).

Forwarder — outbound-only POST to CORE_EVENTS_URL (bearer CORE_EVENTS_TOKEN); throws on non-2xx (the gmail forwarder-must-throw invariant). No inbound surface: deployment stays internal-only.

Boot — the plane activates only on the full env quorum (DATABASE_URL + TOKEN_ENCRYPTION_KEY + CORE_EVENTS_URL); otherwise ops-only, unchanged. Prisma 6 (classic client, archetype parity with the other stateful tools); postinstall generates so CI npm ci keeps working; npm run db:push for deploy.

Tests

20 new (98 total green): first-observation init, edge-triggered policies + cooldown, advance-only-after-delivery with core-down retry, backoff curve + failure-limit termination, expiry-accepted-transition + never-fetches, lease claiming, creation caps + guard fail-fast, credential-never-on-a-read-surface, cancel-disposes-credential, plane-absent-when-unconfigured.

Not in (deliberate)

Core wiring is hadron-server#667 (in flight). Deploy (alpha DB + Doppler quorum) is P4, tracked in #4.

🤖 Generated with Claude Code

Holger Selover-Stephan and others added 4 commits July 14, 2026 11:24
…re seam, config quorum

Spec: cor:web:030:00/:02. The plane boots only when DATABASE_URL +
TOKEN_ENCRYPTION_KEY + CORE_EVENTS_URL are all present; ops-only otherwise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tick, event forwarder

Creation fail-fast (interval floor, mandatory TTL, org cap, guard-check the
URL now, ops-plane header/auth policy), tick with expiry-first ordering,
per-tick full guard, first-observation baseline init, fire-policy gating
(once/every_change/cooldown, edge-triggered), advance-only-after-delivery,
exponential backoff with terminal poll.failed at the limit. Forwarder
throws on non-2xx (never log-and-swallow). Specs: cor:web:030:00..03.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers first-observation init, edge-triggered fire policies, advance-only-
after-delivery with retry, backoff curve + poll.failed limit, expiry-first
with accepted-transition, lease claiming, creation caps + guard fail-fast,
credential never on a read surface, cancel-disposes-credential.

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

Prisma 6 (classic client, the stateful-tool archetype); postinstall
generates the client so CI's npm ci keeps working. The plane boots only on
the full env quorum — ops-only otherwise, unchanged.

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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a stateful polling plane to the microservice, adding Prisma-based database storage, a scheduler to run due poll jobs with exponential backoff, credential encryption at rest, and a set of internal /polls endpoints for job management. The review feedback highlights several critical improvements: catching errors in the scheduler's async interval loop to prevent unhandled promise rejections, consuming the response body in the event forwarder to avoid socket leaks, checking the scheduler lease before forcing a manual job run, and implementing pagination or limits on the organization job listing query to prevent performance bottlenecks.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/polls/scheduler.ts
Comment on lines +69 to +82
export function startScheduler(deps: SchedulerDeps, everyMs = 5_000): () => void {
let running = false;
const timer = setInterval(async () => {
if (running) return; // never overlap loop iterations
running = true;
try {
await runDueOnce(deps);
} finally {
running = false;
}
}, everyMs);
timer.unref?.();
return () => clearInterval(timer);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The setInterval callback is an async function, but any error thrown by runDueOnce is not caught (there is only a try-finally block, no catch). In Node.js, this will result in an unhandled promise rejection, which can crash the process. Add a catch block to safely log the error.

export function startScheduler(deps: SchedulerDeps, everyMs = 5_000): () => void {
  let running = false;
  const timer = setInterval(async () => {
    if (running) return; // never overlap loop iterations
    running = true;
    try {
      await runDueOnce(deps);
    } catch (err) {
      logger.error('scheduler loop failed', {
        err: String((err as Error)?.message ?? err).slice(0, 200),
      });
    } finally {
      running = false;
    }
  }, everyMs);
  timer.unref?.();
  return () => clearInterval(timer);
}

Comment thread src/polls/forwarder.ts
Comment on lines +48 to +69
export function createEventForwarder(options: ForwarderOptions): EventForwarder {
const { coreEventsUrl, coreEventsToken, timeoutMs = 10_000, fetchImpl = fetch } = options;
return async (event) => {
let response: Response;
try {
response = await fetchImpl(coreEventsUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
...(coreEventsToken ? { authorization: `Bearer ${coreEventsToken}` } : {}),
},
body: JSON.stringify(event),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
throw new EventDeliveryError(String((err as Error)?.message ?? err).slice(0, 200));
}
if (response.status < 200 || response.status >= 300) {
throw new EventDeliveryError(`core responded ${response.status}`);
}
};
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

In Node.js 18+ (which uses undici under the hood for global fetch), not consuming the response body can prevent the socket from being released back to the keep-alive pool, potentially leading to socket leaks or connection exhaustion. It is recommended to consume the response body (e.g., via await response.text()) even for error responses.

export function createEventForwarder(options: ForwarderOptions): EventForwarder {
  const { coreEventsUrl, coreEventsToken, timeoutMs = 10_000, fetchImpl = fetch } = options;
  return async (event) => {
    let response: Response;
    try {
      response = await fetchImpl(coreEventsUrl, {
        method: 'POST',
        headers: {
          'content-type': 'application/json',
          ...(coreEventsToken ? { authorization: `Bearer ${coreEventsToken}` } : {}),
        },
        body: JSON.stringify(event),
        signal: AbortSignal.timeout(timeoutMs),
      });
    } catch (err) {
      throw new EventDeliveryError(String((err as Error)?.message ?? err).slice(0, 200));
    }
    await response.text().catch(() => {});
    if (response.status < 200 || response.status >= 300) {
      throw new EventDeliveryError(`core responded ${response.status}`);
    }
  };
}

Comment thread src/routes/polls.ts
Comment on lines +80 to +97
router.post('/:id/run', async (req, res) => {
try {
const job = await service.store.get(req.params.id);
if (!job) {
res.status(404).json({ error: 'not_found', message: 'No such poll job.' });
return;
}
if (job.status !== 'active') {
res.status(409).json({ error: 'poll_not_active', message: `Job is ${job.status}.` });
return;
}
await tick(scheduler, job);
const after = await service.store.get(req.params.id);
res.json({ ok: true, job: after ? toView(after) : null });
} catch (err) {
respondWithError(res, err, 'run');
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The forced run endpoint POST /polls/:id/run executes tick directly on the job without checking if it is currently leased by the scheduler (leaseUntil). If the scheduler is concurrently processing the same job, this can lead to race conditions, double-fetching, or database write conflicts. Consider checking if the job is currently leased before running.

  router.post('/:id/run', async (req, res) => {
    try {
      const job = await service.store.get(req.params.id);
      if (!job) {
        res.status(404).json({ error: 'not_found', message: 'No such poll job.' });
        return;
      }
      if (job.status !== 'active') {
        res.status(409).json({ error: 'poll_not_active', message: `Job is ${job.status}.` });
        return;
      }
      const now = service.now();
      if (job.leaseUntil && job.leaseUntil > now) {
        res.status(409).json({ error: 'poll_leased', message: 'Job is currently leased by the scheduler.' });
        return;
      }
      await tick(scheduler, job);
      const after = await service.store.get(req.params.id);
      res.json({ ok: true, job: after ? toView(after) : null });
    } catch (err) {
      respondWithError(res, err, 'run');
    }
  });

Comment thread src/polls/prismaStore.ts
Comment on lines +87 to +90
async listByOrg(orgId: string): Promise<PollJob[]> {
const rows = await this.prisma.webFetchPollJob.findMany({ where: { orgId }, orderBy: { createdAt: 'desc' } });
return rows.map(toDomain);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The listByOrg query fetches all historical poll jobs for an organization without any limit or pagination. Over time, as completed, expired, failed, or cancelled jobs accumulate, this query can become a performance bottleneck and consume excessive memory. Consider adding pagination or a default limit (e.g., top 100 most recent).

  async listByOrg(orgId: string): Promise<PollJob[]> {
    const rows = await this.prisma.webFetchPollJob.findMany({
      where: { orgId },
      orderBy: { createdAt: 'desc' },
      take: 100,
    });
    return rows.map(toDomain);
  }

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 06ef5a2409

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread package.json
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"postinstall": "prisma generate",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Copy Prisma schema before postinstall

I checked the Dockerfile build order: it runs npm ci --include=dev immediately after copying only package.json and package-lock.json, while this new postinstall invokes prisma generate. Because prisma/schema.prisma is not present at that layer (and the Dockerfile never copies prisma/), image builds will fail before TypeScript compilation instead of producing a deployable service.

Useful? React with 👍 / 👎.

Comment thread src/polls/scheduler.ts
Comment on lines +266 to +268
if (!bestEffort) {
await recordFailure(deps, job, err);
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep expired jobs retrying poll.expired

When an already-expired job cannot deliver poll.expired repeatedly, this calls the generic recordFailure, which applies the failure limit and eventually transitions the job to failed via a best-effort poll.failed. That contradicts the expiry-first contract in this same scheduler: core can permanently miss the poll.expired terminal event during a core outage, and the job stops retrying it after POLL_FAILURE_LIMIT.

Useful? React with 👍 / 👎.

Comment thread src/polls/service.ts
Comment on lines +100 to +101
const input = createPollSchema.parse(raw);
validateConditions(input.conditions);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject contentKind mismatches at creation

When a caller creates a poll with an explicit kind that conflicts with the conditions, such as contentKind: "json" plus a selector_exists condition, this only runs validateConditions and stores an active job. The scheduler later forces kind=json, so evaluateConditions rejects on every tick and the poll backs off until failed instead of returning a creation-time 400 like the evaluate-url path does.

Useful? React with 👍 / 👎.

Comment thread src/polls/service.ts Outdated
Comment on lines +114 to +116
const active = await deps.store.countActiveByOrg(input.orgId);
if (active >= limits.maxActivePerOrg) {
throw new ValidationError('orgId', `the organization already has ${active} active polls (cap ${limits.maxActivePerOrg})`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Enforce the org cap atomically

With concurrent POST /polls requests for the same org when the active count is below the cap, both requests can read the same countActiveByOrg value and then both insert jobs because the check is separate from store.create and there is no transaction, lock, or database constraint around the pair. In production this lets POLL_MAX_ACTIVE_PER_ORG be exceeded even though sequential tests pass.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds the new stateful /polls polling plane to hadrontool-webfetch, including persistent poll jobs, a scheduler loop, and outbound event forwarding to core, while keeping /ops stateless and unchanged unless the polling env quorum is present.

Changes:

  • Introduces /polls CRUD + POST /polls/:id/run, backed by a PollStore seam (in-memory for tests/dev, Prisma/Postgres for prod).
  • Adds the polling scheduler tick loop with lease-based claiming, condition evaluation, fire policies, backoff, terminal events, and credential-at-rest encryption.
  • Wires polling-plane boot behind env quorum and adds Prisma tooling/dependencies plus new test coverage.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/routes/polls.ts Express router for /polls endpoints (create/list/get/cancel/run) with error handling.
src/routes/polls.test.ts Supertest coverage for /polls validation, credential non-leak, cancellation disposal, forced run, and unconfigured absence.
src/polls/store.ts PollStore interface plus in-memory implementation used by tests/dev.
src/polls/service.ts Poll lifecycle service: creation validation, fail-fast guard, encryption-at-create, cancellation disposal, and view mapping.
src/polls/scheduler.ts Scheduler loop + tick() implementation: guarded fetch, evaluation, fire policies, delivery gating, backoff, and terminal transitions.
src/polls/scheduler.test.ts Unit tests for tick invariants: baseline init, policies, advance-only-after-delivery, backoff/limit, expiry-first, and claiming.
src/polls/prismaStore.ts Prisma/Postgres PollStore implementation including atomic claimDue SQL with SKIP LOCKED.
src/polls/forwarder.ts Tool→core event forwarder that throws on any non-2xx / delivery failure.
src/polls/crypto.ts AES-256-GCM credential encryption/decryption with key-id fingerprinting.
src/ops/index.ts Exports header allowlist/auth schema/header normalization for reuse by polling service.
src/index.ts Boots polling plane conditionally (env quorum), instantiates Prisma store/cipher/forwarder, starts scheduler.
src/config.ts Adds polling-plane env vars + derived polling config/quorum gating.
src/app.ts Conditionally mounts /polls router when polling deps are provided.
prisma/schema.prisma Adds WebFetchPollJob Prisma model for persisted poll jobs.
package.json Adds Prisma dependencies and scripts (postinstall, db:push).
package-lock.json Lockfile updates for Prisma + transitive dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/routes/polls.ts
Comment on lines +80 to +93
router.post('/:id/run', async (req, res) => {
try {
const job = await service.store.get(req.params.id);
if (!job) {
res.status(404).json({ error: 'not_found', message: 'No such poll job.' });
return;
}
if (job.status !== 'active') {
res.status(409).json({ error: 'poll_not_active', message: `Job is ${job.status}.` });
return;
}
await tick(scheduler, job);
const after = await service.store.get(req.params.id);
res.json({ ok: true, job: after ? toView(after) : null });
Comment thread src/polls/store.ts
Comment on lines +101 to +118
async create(job: NewPollJob): Promise<PollJob> {
const full: PollJob = {
...job,
id: `job-${++this.seq}`,
baselineHash: null,
baselineValues: null,
lastNotifiedHash: null,
lastStatus: null,
lastCheckedAt: null,
lastTriggeredAt: null,
triggerCount: 0,
consecutiveFailures: 0,
leaseUntil: null,
createdAt: new Date(job.nextRunAt),
};
this.jobs.set(full.id, full);
return { ...full };
}
Comment thread package.json
Comment on lines 14 to +19
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"postinstall": "prisma generate",
"db:push": "prisma db push"
Comment thread src/polls/service.ts Outdated
Comment on lines +114 to +117
const active = await deps.store.countActiveByOrg(input.orgId);
if (active >= limits.maxActivePerOrg) {
throw new ValidationError('orgId', `the organization already has ${active} active polls (cap ${limits.maxActivePerOrg})`);
}
…try, atomic org cap, leased forced-run, hardening

- Dockerfile: copy prisma/ before npm ci (postinstall runs prisma generate),
  prisma CLI to prod deps + regenerate after npm prune (prune can drop the
  generated .prisma client).
- poll.expired delivery retries forever (capped backoff) instead of hitting
  the failure limit and downgrading to poll.failed — core must never
  permanently miss a terminal event (cor:web:030:03).
- Org cap enforced atomically in the store (pg advisory xact lock around
  count+insert); creation rejects an explicit contentKind that conflicts
  with the conditions (a doomed job).
- POST /polls/:id/run claims the scheduler's lease (claimOne) — 409
  poll_leased while the loop holds the job.
- startScheduler catches loop errors (no unhandled rejection); forwarder
  drains response bodies (undici socket reuse); listByOrg capped at 100;
  InMemory createdAt uses a real clock.

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

Copy link
Copy Markdown
Member Author

All review points addressed:

  • Docker build breaks on postinstall (@codex P1, @Copilot): prisma/ is now copied before npm ci, the prisma CLI moved to prod dependencies, and prisma generate re-runs after npm prune (prune can drop the generated .prisma client from node_modules).
  • Expired jobs downgraded to poll.failed (@codex P2): terminal poll.expired delivery now retries forever with capped backoff — the failure limit no longer applies to it, so core can't permanently miss a terminal event during an outage. The job is inert by then (no fetches, one delivery attempt per backoff). Test added (6 failed deliveries past limit → still retrying → delivered).
  • Org cap not atomic (@codex P2, @Copilot): the cap check moved inside the store as createCapped — a pg_advisory_xact_lock(hashtext(orgId)) transaction around count+insert, so concurrent creates/replicas can't overshoot. In-memory impl is atomic by construction.
  • contentKind/conditions mismatch stored as a doomed job (@codex P2): rejected at creation with validation_error on contentKind, matching evaluate-url. Test added.
  • Forced run races the scheduler (@gemini-code-assist, @Copilot): POST /polls/:id/run now claims the job through the same lease (claimOne, atomic compare-and-set) and returns 409 poll_leased when the loop holds it. Test added.
  • Unhandled rejection in the scheduler loop (@gemini-code-assist, high): caught + logged.
  • Forwarder socket reuse (@gemini-code-assist): response bodies drained even on error statuses.
  • Unbounded listByOrg (@gemini-code-assist): capped at 100 most recent, documented on the seam.
  • Future createdAt in InMemory store (@Copilot): now uses an injectable clock, not nextRunAt.

101 tests green, typecheck clean.

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create an environment for this repo.

@shadowbrush
shadowbrush merged commit acb0921 into main Jul 14, 2026
1 check passed
@shadowbrush
shadowbrush deleted the feat/polls-plane branch July 14, 2026 20:12
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