Tier-2 Headless Fleet — implementation spec (#68)

Generated 2026-06-08 by the tier2-headless-fleet workflow. The buildable plan; Browserbase OUT, fully self-hosted.

Oracle Cloud Always Free Ampere A1 (ARM), with the AMD E2.1.Micro (x86, 1GB) as the zero-flake-change fallback. WHY: the lean device is a long-running loop (capability, lease, heartbeat, scrape, submit, sleep) with NO inbound traffic, so every scale-to-zero free-web-service tier (Koyeb, Render, Back4App, Northflank) would hibernate it, wrong shape. Oracle is a genuinely always-on micro-VM; the scrape loop own CPU bursts keep it non-idle. Koyeb is doubly out: its single free slot is already the Typst service. Fly.io free tier is dead in 2026; GCP free e2-micro is already CORBET-US-E2-MICRO. The image is verified about 65.56 MiB uncompressed closure (curl plus cacert) plus about 5-8MB static-musl binary, so about 25-35 MiB gzipped tarball, roughly 7x under the 512MB ceiling (fits even a 256MB box); runtime RSS is tiny (curl is a short-lived child per fetch, the loop sleeps). RUN RECIPE (x86 zero-effort path): nix copy from the boot.corbet.ch signed cache then podman load of the gzipped docker-archive; OR scp the tarball and podman load. Run under systemd via a podman Quadlet at /etc/containers/systemd/jobcache-device.container: Image jobcache-device latest, ReadOnly true, Tmpfs /tmp rw mode 1777 size 64m (proves the FS contract: only /tmp writable, all broker.rs needs), DropCapability ALL, NoNewPrivileges true, MemoryMax 256M, Restart always. Env: JOBCACHE_API_URL to api.jobcache.corbet.ch, JOBCACHE_CONTROL_TOKEN from an EnvironmentFile written by sops (secrets/jobcache-control.yml, mode 0600, NEVER baked into the image), JOBCACHE_DEVICE_ID device_oracle_arch_host, JOBCACHE_CPU_CLASS oracle-arm-free (or oracle-amd-micro), JOBCACHE_TASK_CLASSES scrape (Tier-1 curl-only, NO render), and CRUCIALLY OMIT JOBCACHE_BROWSER_URL so RenderEnv stays off and render tasks are never leased here. OMIT JOBCACHE_DEVICE_TIER and JOBCACHE_DEVICE_FORMAT (the Rust binary ignores them, they are bun-worker vestiges). For ARM A1: first build packages.aarch64-linux.jobcache-device-image after the one-line muslPkgs flake tweak. Convert the Oracle tenancy to Pay-As-You-Go to neutralize the 7-day idle-reclaim while staying at zero cost within Always-Free limits. Bare-binary fallback (no podman): apt install curl ca-certificates, scp the static-musl binary to /usr/local/bin, run under a DynamicUser systemd unit with ProtectSystem strict plus PrivateTmp true.

jobich lessons applied (our own words — no code copied)

  • Multi-tier rendering, NOT render-everything: jobich runs about 60 Tier-1 httpx scrapers natively and only spawns Playwright for about 15 JS-heavy boards (20-30 percent of traffic). We keep curl as the device default fetch and only the explicitly-flagged render task-class boards (and the headless-proxy Method, gated by sporewright fetch_rendered plus-infinity cell) reach the Playwright fleet. The lean 512MB device runs scrape-only and never renders.
  • Process-isolation for the browser, adapted to a container/context boundary: jobich runs each Playwright job in its own subprocess so a crash cannot kill the parser. We translate subprocess-per-job to the render service being a separate k3s Deployment the device POSTs to over HTTP, plus a fresh browser context per render closed in a finally block. Same isolation property, no in-process browser in the Rust device.
  • Minimal curated stealth, never a stealth plugin: jobich found that for a low-volume Swiss crawler, UA rotation and puppeteer-extra/playwright-stealth are MORE detectable and slow with a heavy closure. We apply only the high-signal levelers (single realistic UA, de-CH locale plus Europe/Zurich timezone, fixed viewport, disable-blink-features AutomationControlled, and an init script masking navigator.webdriver, plugins, and window.chrome) and skip the plugin entirely.
  • Treat a captcha or WAF gate as a fall-through, never a success: jobich never stores a captcha page as a real result. Our render service runs an in-DOM fingerprint check after settle and returns 403 captcha-gate, which the device maps to FetchError Unavailable (clean skip via observe_with_rendered_fallback) and the TS router maps to a throw that falls through to a metered scraper. So the self-hosted free fleet absorbs basic and Cloudflare JS but DataDome/AWS-WAF correctly escalate.
  • Swiss-market settle jitter, no fixed intervals: jobich uses randomized per-domain delays rather than a fixed cadence to avoid detection. We add a 200-900ms randomized post-load settle in the render service and slower randomized worker pacers (idle 60s, active 3s plus jitter) on the tier-2 device.
  • Adapter and manifest-driven routing, extraction stays server-side: jobich sources.yaml maps each board to platform metadata and keeps extraction logic out of the thin client. We mirror this: the board binding config carries an explicit task_class render flag (no fake auto-JS-detection heuristic, per no-silent-defaults), the device stays a thin client that just POSTs a URL and gets HTML back, and JSON-LD extraction happens at the render leaf while still returning full html so existing adapters parse unchanged.
  • Render listing and description fetching are decoupled and restartable: jobich scrapes listings then enriches descriptions in a separate pass that does not fail the scrape on a missing description. Our render service returns raw html and jsonld only; classification and embedding stay in separate enrichment workers, and a render board discovered detail URLs plus freshness re-fetches inherit the render class so they route to render-capable pods rather than silently falling back to curl devices that cannot render them (the load-bearing planner fix).
  • Observability per board: jobich logs per-source success rates, rate-limit events, and timings. The render service exposes /health (uptime, inFlight, totalRenders, errors) and /metrics (Prometheus render-total by outcome, inflight gauge, duration histogram) so render outcomes are visible per-pod and aggregatable at the device and router level.

Build plan (ordered; [code-now] vs [build-server-SAVED])

  1. [code-now] flake.nix: parameterize muslPkgs on target arch so packages.aarch64-linux.* yields a REAL arm64 static binary. Today pkgs.pkgsCross.musl64 is hardcoded x86_64, so every system attr builds x86_64. Replace with: if pkgs.stdenv.hostPlatform.isAarch64 then pkgs.pkgsCross.aarch64-multiplatform-musl else pkgs.pkgsCross.musl64. mkBin, jobcache-device, and jobcache-device-image all flow through muslPkgs. Verify with nix eval of packages.aarch64-linux.jobcache-device-image.outPath. This is the ONLY change the lean 512MB ARM image needs.

  2. [code-now] Decision (no code): the Rust device advertises render purely through JOBCACHE_TASK_CLASSES. There is NO fetch_rendered capability FLAG on the Rust wire report (the Rust device routes via the adapter registry where adapters call env.fetch_rendered; offers-render equals RenderEnv-active equals JOBCACHE_BROWSER_URL set). Routing signal is task-class only; no contract.rs change.

  3. [code-now] jobcache/shared/src/device-contract.ts: append render to JOBCACHE_TASK_CLASSES (7 entries; JobcacheTaskClassSchema z.enum derives automatically). Add active_render_tasks NonNegativeIntegerSchema.optional() to JobcacheDeviceLoadSchema (additive and back-compat: older devices omit it, broker treats absent as 0). Mirror the existing reparse precedent.

  4. [code-now] jobcache/migrations/add-render-task-class.sql (NEW): sibling of add-reparse-task-class.sql, extend the CrateDB task_class column CHECK or enum to admit render. ROLLOUT GATE: this migration plus the contract and broker change MUST deploy BEFORE any tier-2 device advertises render, else a render-advertising capability report 400s against an old broker (schemas are strict). Same ordering the reparse class followed.

  5. [code-now] crates/jobcache-device/src/main.rs: widen parse_task_classes ALLOWED from 6 to 7 entries adding render; add active_render_tasks 0 to the CurrentLoad literal in build_capability; add mod render; add a startup hard-warning when render is in task_classes but JOBCACHE_BROWSER_URL is unset (prevents the silent-churn footgun).

  6. [code-now] crates/jobcache-device/src/broker.rs: add pub active_render_tasks u32 to the CurrentLoad struct (serde field; emitted as 0 soft hint, matching the other active tasks fields).

  7. [code-now] crates/jobcache-device/src/render.rs (NEW): RenderEnv wrapping CurlEnv, impl jobcache Env. user_agent, rate_limit, and fetch delegate to inner CurlEnv (Tier-1 plain leg stays curl); fetch_rendered POSTs url, timeout_ms, wait_until to JOBCACHE_BROWSER_URL via curl stdin-pipe (data-binary at-dash, matching env.rs fetch, no temp file on the tokenless path), parsing html, final_url, status into a Response. RenderConfig from_env reads JOBCACHE_BROWSER_URL (None means bare CurlEnv, fetch_rendered Unavailable, observe_with_rendered_fallback skips cleanly), JOBCACHE_BROWSER_TIMEOUT_MS (default 45000), optional JOBCACHE_BROWSER_TOKEN. Error map: curl 28 to Timeout; curl 5/6/7 to Unavailable (service down equals skip, not hard fail); HTTP 429/503 to RateLimited 0; HTTP 403 to Unavailable (captcha-gate); other non-2xx to Unavailable. Token branch (temp-file body plus stdin config) is a documented TODO. Unit test: from_env None without JOBCACHE_BROWSER_URL.

  8. [code-now] crates/jobcache-device/src/adapter.rs: in handle(), bind let fallback equals CurlEnv from_env FIRST (lifetime), then let env dyn Env equals match render RenderEnv from_env Some ref r then r else and-fallback. The naive match-arm and-CurlEnv-from_env will not compile (both briefs flagged this). Widen run_observe and run_discover from and-CurlEnv to and-dyn-Env (bodies unchanged). KEEP run_reparse_grant internal fetch_env on bare CurlEnv (must NOT render the presigned ciphertext).

  9. [code-now] jobcache/interface/src/api/device-control.ts: taskClassLoad add a render branch returning numberValue load.active_render_tasks 0; add a render resource-policy default (max_concurrency 1, timeout_ms about 90000, larger max_bytes about 8MB, no metered or battery), render is slow and heavy; observeTaskFromDiscovery (line 1315) change task_class scrape to inherit discoverTask.task_class equals render then render else scrape so a JS board discovered DETAIL fetches inherit render. eligibleTaskClasses and selectReadyTasks need NO change.

  10. [code-now] jobcache/interface/src/api/planner/source-bindings.ts: planFreshnessRefetchTasks re-fetch payload task_class scrape becomes row.task_class equals render then render else scrape (thread task_class through FreshnessRefetchRow and its SELECT if absent) so a render board stale-ad re-fetch stays render. Main planSourceBindingTasks path already passes taskClass limits.task_class and needs NO change. A JS board is flagged by an explicit task_class render in binding config (no auto JS-detection heuristic). This detail and freshness inheritance is the load-bearing planner fix.

  11. [code-now] jobcache/render/ (NEW perspective dir): self-hosted Playwright HTTP service. package.json (playwright PINNED 1.59.1 equals nixpkgs playwright-driver, fastify, prom-client), src/server.ts (Fastify; POST /render returns finalUrl, status, html, jsonld array; GET /health and /metrics; one Browser, context-per-render, semaphore MAX_CONCURRENT_RENDERS 3 returns 503 at-capacity; captcha-fingerprint returns 403; SIGTERM drain), src/stealth.ts (minimal curated: de-CH locale, Europe/Zurich tz, single UA, 1280x800, disable-blink-features AutomationControlled, init-script masks webdriver, plugins, chrome; NO puppeteer-extra), src/jsonld.ts (in-page ld+json collector), src/server.test.ts (contract shape vs fixture, no real browser). NO Dockerfile, the Nix image is canonical.

  12. [code-now] flake.nix: add jobcache-render-image (3rd package output) via dockerTools.buildLayeredImage. contents are nodejs_22, buildNpmPackage renderServer, playwright-driver.browsers (chromium rev 1217), cacert, fontconfig, dejavu_fonts, liberation_ttf, noto-fonts-cjk-sans, bashInteractive, coreutils. Env: PLAYWRIGHT_BROWSERS_PATH to the browsers store path plus PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD 1 (resolve chromium from the Nix store, never download), SSL_CERT_FILE, NODE_EXTRA_CA_CERTS, FONTCONFIG_FILE, MAX_CONCURRENT_RENDERS 3. extraCommands mkdir 1777 for tmp and dev-shm. npmDepsHash is a build-brain FIXME. Add a build-time assertMsg: package.json playwright equals playwright-driver.version (highest-risk lockstep: mismatch means a chromium-rev dir not in the store, pods fail at launch). Header comment SPLITS lean device image (under 512MB, free micro, NO chromium) from render image (about 1-1.5GB, k3s ONLY).

  13. [code-now] jobcache/ingest/src/scrapers/playwright.ts (NEW) plus register in index.ts: Scraper (id playwright, js true, antiBot none/basic/cloudflare) POSTing to JOBCACHE_RENDER_URL /render; available probes /health (15s cache); fetch maps 503 to throw (router retries next), 403 captcha-gate to throw (fall through to metered browserbase/firecrawl), 2xx to html, finalUrl, status. Register FIRST among JS-capable scrapers (before browserbase and firecrawl) so the free fleet absorbs JS traffic; metered providers only see captcha fall-throughs. Only register when JOBCACHE_RENDER_URL set. Zero router.ts change.

  14. [code-now] k3s manifests jobcache/ingest/deploy/kubernetes/: render-deployment.yaml (Deployment jobcache-render, replicas 2, requests cpu 250m mem 768Mi, limits cpu 2 mem 1536Mi; emptyDir medium Memory 512Mi at dev-shm (canonical chromium-in-k8s fix; do NOT pass disable-dev-shm-usage), emptyDir at tmp; readiness and liveness on /health; runAsNonRoot uid 1000, drop ALL caps, seccomp RuntimeDefault, no-sandbox (pod is the single-tenant isolation boundary); terminationGracePeriodSeconds 30) plus render-service.yaml (ClusterIP jobcache-render 80 to 3000). Add both to kustomization.yaml.

  15. [code-now] jobcache/ingest/deploy/kubernetes/deployment-render.yaml (NEW): TIER-2 device Deployment, SAME lean jobcache-device:latest image (chromium NOT in the device image, render is a client capability), env JOBCACHE_TASK_CLASSES render, JOBCACHE_DEVICE_TIER headless (identity only, NOT a routing gate), JOBCACHE_BROWSER_URL to the cluster render Service (flips RenderEnv on), JOBCACHE_BROWSER_TIMEOUT_MS 45000, slower pacers, fewer replicas, limits cpu 1 mem 512Mi. The free 512MB micro-server runs the SAME image but OMITS JOBCACHE_BROWSER_URL (CurlEnv only, render tasks never leased to it). Add to kustomization.yaml.

  16. [code-now] LOCAL verification gate: cargo build -p jobcache-device (the dyn Env widening plus render.rs typecheck), cargo test -p jobcache-device (render.rs from_env-None plus existing adapter tests), bun test jobcache/shared and jobcache/interface (render round-trips JobcacheTaskClassSchema; taskClassLoad and eligibleTaskClasses render case), tsc jobcache/render and playwright.ts, nix eval of jobcache-render-image.outPath (lockstep assert fires) plus packages.aarch64-linux.jobcache-device-image.outPath. All eval and typecheck clean WITHOUT realising the slow musl or npm builds.

  17. [build-server-SAVED] On the build-brain (boot.corbet.ch) via the fleet-build CronJob (infra repo infra/infra/scripts/fleet-build.sh; this repo infra/ is now a MOVED.md stub): nix build packages.aarch64-linux.jobcache-device-image (lean ARM) plus packages.x86_64-linux.jobcache-device-image (lean x86, already built) plus jobcache-render-image (fill npmDepsHash on first build from nix printed hash; lockstep assert gates it). nix copy to the signed zstd cache, served from boot.corbet.ch. GATED on build-brain keys/access.

  18. [build-server-SAVED] Provision the free 512MB server (Oracle Cloud Always Free; user has an Oracle login) plus run the lean device per the run recipe; deploy jobcache-render plus jobcache-device-render to k3s (ctr namespace k8s.io images import from boot.corbet.ch, kubectl apply -k). DEPLOY ORDER: contract, broker, migration FIRST, then render service, then tier-2 device pods, then the free micro-server. GATED on cluster plus Oracle keys/access.

Artifacts to write

  • /home/richc/Documents/GitHub/careervector/flake.nix — EDIT: (1) replace muslPkgs equals pkgs.pkgsCross.musl64 with the arch-parameterized selection (isAarch64 picks aarch64-multiplatform-musl, else musl64) so packages.aarch64-linux.* builds a real arm64 lean image. (2) ADD jobcache-render-image (3rd package output) via dockerTools.buildLayeredImage with nodejs_22 plus buildNpmPackage renderServer plus playwright-driver.browsers (chromium rev 1217) plus fonts plus cacert; Env pins PLAYWRIGHT_BROWSERS_PATH and SKIP_DOWNLOAD; extraCommands makes tmp and dev-shm 1777; add the playwright-version-lockstep assertMsg (package.json playwright equals playwright-driver.version). (3) Header comment splitting lean device image (under 512MB, free micro, no chromium) from render image (about 1-1.5GB, k3s only). jobcache-device-image itself UNCHANGED.
  • /home/richc/Documents/GitHub/careervector/crates/jobcache-device/src/render.rs — NEW: RenderEnv wrapping CurlEnv, impl jobcache Env. user_agent, rate_limit, fetch delegate to inner CurlEnv; fetch_rendered POSTs url, timeout_ms, wait_until to JOBCACHE_BROWSER_URL via curl stdin-pipe (data-binary at-dash, matching env.rs fetch), parses html, final_url, status into Response. RenderConfig from_env reads JOBCACHE_BROWSER_URL (None means tier-1 fallback), JOBCACHE_BROWSER_TIMEOUT_MS (default 45000), optional JOBCACHE_BROWSER_TOKEN. Error map: curl 28 to Timeout, curl 5/6/7 to Unavailable, HTTP 429/503 to RateLimited 0, HTTP 403 to Unavailable (captcha-gate), other non-2xx to Unavailable. Token-via-temp-file branch is a documented TODO. Unit test: from_env None without JOBCACHE_BROWSER_URL.
  • /home/richc/Documents/GitHub/careervector/crates/jobcache-device/src/main.rs — EDIT: parse_task_classes ALLOWED 6 to 7 entries add render; build_capability CurrentLoad add active_render_tasks 0; add mod render; add startup hard-warning when render is in task_classes but JOBCACHE_BROWSER_URL is unset.
  • /home/richc/Documents/GitHub/careervector/crates/jobcache-device/src/broker.rs — EDIT: add pub active_render_tasks u32 to the CurrentLoad struct (serde field, soft-hint 0).
  • /home/richc/Documents/GitHub/careervector/crates/jobcache-device/src/adapter.rs — EDIT: handle() bind let fallback equals CurlEnv from_env first (lifetime), then let env dyn Env equals match render RenderEnv from_env Some ref r then r else and-fallback; widen run_observe and run_discover signatures from and-CurlEnv to and-dyn-Env. KEEP run_reparse_grant internal fetch_env on bare CurlEnv (must not render the presigned ciphertext).
  • /home/richc/Documents/GitHub/careervector/jobcache/shared/src/device-contract.ts — EDIT: append render to JOBCACHE_TASK_CLASSES (7 entries); add active_render_tasks NonNegativeIntegerSchema.optional() to JobcacheDeviceLoadSchema. Mirror the reparse precedent.
  • /home/richc/Documents/GitHub/careervector/jobcache/migrations/add-render-task-class.sql — NEW: sibling of add-reparse-task-class.sql, extend the CrateDB task_class column CHECK or enum to admit render. Deploy BEFORE any render-advertising device.
  • /home/richc/Documents/GitHub/careervector/jobcache/interface/src/api/device-control.ts — EDIT: taskClassLoad add render to active_render_tasks branch; add a render resource-policy default (max_concurrency 1, timeout_ms about 90000, larger max_bytes, no metered or battery); observeTaskFromDiscovery task_class inherit discoverTask.task_class equals render then render else scrape.
  • /home/richc/Documents/GitHub/careervector/jobcache/interface/src/api/planner/source-bindings.ts — EDIT: planFreshnessRefetchTasks re-fetch payload task_class inherit render from row.task_class (thread it through FreshnessRefetchRow and SELECT if absent). Main bind path unchanged.
  • /home/richc/Documents/GitHub/careervector/jobcache/render/package.json — NEW: name jobcache-render, type module; deps playwright PINNED 1.59.1 (equals nixpkgs playwright-driver), fastify, prom-client; devDeps tsx, types-node, typescript. Bumping playwright requires bumping the flake pin in lockstep (assert enforces).
  • /home/richc/Documents/GitHub/careervector/jobcache/render/src/server.ts — NEW: Fastify render service. POST /render with url, waitUntil, timeoutMs, stealth, locale, userAgent returns finalUrl, status, html, jsonld; GET /health returns ok, uptime, chromiumRev, inFlight, totalRenders, errors; GET /metrics. One Browser (no-sandbox, disable-gpu, NO disable-dev-shm-usage), context-per-render closed in finally, in-process semaphore returns 503 at-capacity, captcha-fingerprint check returns 403 captcha-gate, 200-900ms settle jitter, SIGTERM drain, hard timeout 45s.
  • /home/richc/Documents/GitHub/careervector/jobcache/render/src/stealth.ts — NEW: minimal curated stealth (NO puppeteer-extra). de-CH locale, Europe/Zurich tz, single realistic Chrome UA, 1280x800 viewport, Accept-Language de-CH, disable-blink-features AutomationControlled flag, init script masking navigator.webdriver, plugins, languages, plus window.chrome stub.
  • /home/richc/Documents/GitHub/careervector/jobcache/render/src/jsonld.ts — NEW: in-page collector of script type application/ld+json blocks (schema.org JobPosting). Parse each; skip malformed (never fabricate). Returns unknown array.
  • /home/richc/Documents/GitHub/careervector/jobcache/render/src/server.test.ts — NEW: contract-shape test of /render and /health against a local fixture HTTP server (no real chromium needed for the wire contract).
  • /home/richc/Documents/GitHub/careervector/jobcache/ingest/src/scrapers/playwright.ts — NEW: makePlaywrightScraper Scraper (id playwright, js true, antiBot none/basic/cloudflare) POSTing to JOBCACHE_RENDER_URL /render; available probes /health (15s cache); fetch maps 503 to throw (retry next), 403 to throw (captcha-gate fall-through), 2xx to html, finalUrl, status. Throws on empty html.
  • /home/richc/Documents/GitHub/careervector/jobcache/ingest/src/scrapers/index.ts — EDIT: register makePlaywrightScraper FIRST among JS-capable scrapers (before browserbase and firecrawl) when JOBCACHE_RENDER_URL is set; warn-log when unset.
  • /home/richc/Documents/GitHub/careervector/jobcache/ingest/deploy/kubernetes/render-deployment.yaml — NEW: Deployment jobcache-render, replicas 2, image jobcache-render:latest IfNotPresent, requests cpu 250m mem 768Mi limits cpu 2 mem 1536Mi, dev-shm emptyDir medium Memory 512Mi plus tmp emptyDir, readiness and liveness /health, runAsNonRoot uid 1000 drop-ALL seccomp RuntimeDefault, terminationGracePeriodSeconds 30, MAX_CONCURRENT_RENDERS 3.
  • /home/richc/Documents/GitHub/careervector/jobcache/ingest/deploy/kubernetes/render-service.yaml — NEW: ClusterIP Service jobcache-render, port 80 to targetPort http (3000), so device pods reach the cluster-internal render URL.
  • /home/richc/Documents/GitHub/careervector/jobcache/ingest/deploy/kubernetes/deployment-render.yaml — NEW: tier-2 device Deployment jobcache-device-render reusing the SAME lean jobcache-device:latest image, env JOBCACHE_TASK_CLASSES render, JOBCACHE_DEVICE_TIER headless, JOBCACHE_BROWSER_URL to the cluster render Service, JOBCACHE_BROWSER_TIMEOUT_MS 45000, slower pacers, fewer replicas, limits cpu 1 mem 512Mi (chromium lives in jobcache-render, not here).
  • /home/richc/Documents/GitHub/careervector/jobcache/ingest/deploy/kubernetes/kustomization.yaml — EDIT: add render-deployment.yaml, render-service.yaml, deployment-render.yaml to resources.

Open questions

  • Broker capability advertisement gap: a tier-1 curl device and a tier-2 render device differ ONLY by JOBCACHE_TASK_CLASSES and JOBCACHE_BROWSER_URL; there is no fetch_rendered capability FLAG on the Rust wire report (routing is task-class only). If a render device is misconfigured with JOBCACHE_TASK_CLASSES render but JOBCACHE_BROWSER_URL unset, it will lease render tasks and fail them (Unavailable, retryable churn). The startup hard-warning mitigates but does not prevent. Should the broker additionally refuse to lease render tasks to a device whose report does not positively assert render capability, or is the task-class advertisement plus startup guard sufficient?
  • Render env-var naming: the briefs disagree (JOBCACHE_BROWSER_URL plus service jobcache-render vs JOBCACHE_RENDER_URL plus service jobcache-render). I locked the Rust device on JOBCACHE_BROWSER_URL and the TS scraper on JOBCACHE_RENDER_URL because they point at the same service from two different consumers, but confirm whether you want ONE env-var name shared across the Rust device and the TS ingest, or the two-name split as drafted.
  • npmDepsHash and lockstep: the buildNpmPackage npmDepsHash is a build-brain FIXME (filled on first build from nix printed expected hash) and the playwright-version lockstep assert assumes package.json dependencies.playwright is a bare version string (1.59.1), not a range. Confirm the pinned nixpkgs rev still resolves playwright-driver 1.59.1 and chromium rev 1217 at build time, or the assert plus image build will need the version bumped in lockstep.
  • no-sandbox posture: the render pods run chromium with no-sandbox (the k8s pod, non-root, drop-ALL caps, seccomp RuntimeDefault, is the only isolation boundary). This is the standard headless-in-container tradeoff for a single-tenant job-board scraper, but it removes chromium internal sandbox against a chromium RCE from attacker-controlled JS. Accept as-is, or add a gVisor/Kata runtimeClass for the render Deployment?
  • Source flagging: which boards get task_class render is currently a manual per-source decision in binding config (intentionally no auto JS-detection, per no-fake-heuristic). That means a JS board not yet flagged stays on scrape and is silently skipped by curl devices. Do you want a QA or planner surface that lists which sources are JS-only-but-unflagged, or is manual curation from the jobich platform-quirks registry the intended workflow?

DRAFT — deviceRender

plan

Design: device render task class as a thin Playwright client (#68)

Core insight

crates/jobcache/src/adapter.rs already has the seam: Env::fetch_rendered(req) (default returns FetchError::Unavailable("rendered fetch")). Every JS adapter (the playwright-html family, sf-spa, etc.) calls env.fetch_rendered(...) or routes through observe_with_rendered_fallback. The Rust device's CurlEnv (env.rs) does NOT override fetch_rendered, so today it returns Unavailable and those Methods are dropped. A tier-2 device is therefore JUST CurlEnv + an overridden fetch_rendered that POSTs the URL to the cluster-internal Playwright service. The extraction + broker-submit path (adapter::run_observe / run_discover) is byte-identical to the curl path — no new submit code.

The capability that gates rendering is ALREADY modelled end-to-end: method-chain.ts CAP_FETCH_RENDERED = "fetch_rendered", METHOD_NEEDS_RENDER["headless-proxy"] = true, and gateCapabilities writes priv:fetch_rendered = +∞ on headless-proxy for any device lacking the cap. The TS device sets capabilities = env.fetchRendered ? [CAP_FETCH_RENDERED] : [] (device-cycle.ts). So the device-side capability is "does this device offer fetch_rendered". The Rust device needs the equivalent: offer the cap when JOBCACHE_BROWSER_URL is set.

Two routing layers (both pre-exist, both extended)

  1. Method gate (per-task, device-local, already live): which Method runs on this device. headless-proxy is dropped unless the device offers fetch_rendered. This already works for the TS device; we replicate it for Rust.
  2. Task-class lease filter (broker-side, the NEW tier match): task_class is a coarse routing key on the queue. eligibleTaskClasses (device-control.ts) intersects the device's supported_task_classes/allowed_task_classes with the task's task_class, and selectReadyTasks filters the SQL queue by task_class IN (...). Adding a render task class lets the planner flag JS-only boards as render so ONLY tier-2/headless devices (which advertise render in their classes) ever lease them — tier-1 nano-curl devices never even see them in the queue.

Why both, not just the Method gate

The Method gate alone would let a tier-1 device LEASE a JS board task, run the chain, find every Method gated out, and return adapter_unreachable_method (retryable) — wasted lease cycles + churn. The render task class makes the queue filter do the work up front: the broker never offers a render task to a non-render device. This mirrors how reparse is its own class today.


File-by-file implementation plan

A. Schema/contract — add the render class + capability advertisement

  • jobcache/shared/src/device-contract.ts
    • JOBCACHE_TASK_CLASSES: append "render"["scrape","chunk","enrich","embed","verify","reparse","render"]. (JobcacheTaskClassSchema derives from it automatically.)
    • JobcacheDeviceLoadSchema: add active_render_tasks: NonNegativeIntegerSchema.optional() (additive, back-compat — older devices omit it; broker treats absent as 0).
    • Add a JOBCACHE_TASK_KINDS entry for the render fetch if a distinct kind is wanted; simplest is to REUSE "fetch-page" (the render device's fetch_rendered makes the headless-proxy Method feasible, the kind stays fetch-page). Recommend reuse — no new kind.
  • crates/jobcache-device/src/main.rs::parse_task_classes: extend ALLOWED from [&str; 6] to include "render" (→ [&str; 7]). This lets JOBCACHE_TASK_CLASSES=render pass the filter.
  • crates/jobcache-device/src/broker.rs::CurrentLoad: add pub active_render_tasks: u32 and emit it in build_capability (main.rs). Keep it 0 (the sink reports a soft hint).

B. Broker-side — task-class load + tier eligibility

  • jobcache/interface/src/api/device-control.ts::taskClassLoad: add if (taskClass === "render") return numberValue(load.active_render_tasks, 0);.
  • device-control.ts resource-policy default map (the resourcePolicyFor / DEFAULT_TASK_CLASS_POLICY area near line ~1315 task_class: "scrape"): add a render policy default (lower max_concurrency, bigger timeout_ms ≈ 60s, bigger max_bytes). Render is slow + heavy.
  • No change needed to eligibleTaskClasses/isAllowedTaskClass/selectReadyTasks — they're already class-generic; once render is in the enum and a device advertises it, the intersection + SQL filter route correctly.

C. Planner — flag JS boards as render

  • jobcache/interface/src/api/planner/source-bindings.ts (line ~417 task_class: taskClass(limits.task_class)): the binding's limits.task_class already drives the class. A JS-only board sets task_class: "render" in its binding config (source-catalog.ts / binding row). The planner needs NO code change — it already passes limits.task_class through. The ONLY change: ensure taskClass() (the local coercer at line ~1053) accepts "render" (it validates against DeviceTaskEnqueue["task_class"], which derives from the contract enum, so once the enum has render this is automatic).
    • Optional ergonomics: if boards carry a boolean requires_render/js_render flag in source config instead of an explicit class, add a one-line derivation in the planner: task_class: limits.requires_render ? "render" : taskClass(limits.task_class). Recommend the explicit task_class in binding config (no fake heuristic, per "no silent defaults").
  • device-control.ts::observeTaskFromDiscovery (line ~1312) and planner::planFreshnessRefetchTasks (line ~517): these hardcode task_class: "scrape". For a render board, a discovered detail URL / freshness re-fetch must INHERIT the discover task's class. Change both to carry the parent's task_class (thread discoverTask.task_class / row.task_class) so a render board's detail + re-fetch tasks stay render. This is the load-bearing planner fix — without it, only the discover task is render-routed and the detail fetches fall back to scrape and get leased by curl devices that can't render them.

D. Rust device — the render thin client (the heart)

  • NEW crates/jobcache-device/src/render.rs: a RenderEnv that wraps CurlEnv and overrides fetch_rendered to POST the target URL to the Playwright service. fetch (plain) delegates to the inner CurlEnv (so the "plain HTTP first, render fallback" adapter pattern still uses cheap curl for the plain attempt). fetch_rendered shells curl to POST { "url": ..., "timeout_ms": ... } to JOBCACHE_BROWSER_URL and parses { "html": ..., "final_url": ..., "status": ... } back into a Response. Pure-Rust, curl-shell-out — same no-TLS-stack property as broker.rs/env.rs.
  • crates/jobcache-device/src/env.rs: factor the curl-POST helper so render.rs reuses the same stdin-config token discipline if the browser service needs auth (it's cluster-internal, so likely tokenless; keep optional JOBCACHE_BROWSER_TOKEN).
  • crates/jobcache-device/src/adapter.rs::handle: change let env = CurlEnv::from_env(); to select the env by capability: when JOBCACHE_BROWSER_URL is set, build RenderEnv::from_env() (which offers fetch_rendered); else CurlEnv::from_env(). Pass &dyn Env to run_observe/run_discover (they already take env: &CurlEnv — widen the signature to &dyn Env so either env works). The extraction + submit path is unchanged.
  • crates/jobcache-device/src/main.rs: no logic change beyond parse_task_classes + the load field; the cycle already routes through adapter::handle.
  • crates/jobcache-device/src/main.rs / adapter.rs: the capability report's supported_task_classes/allowed_task_classes come from config.task_classes. A tier-2 device sets JOBCACHE_TASK_CLASSES=render (or scrape,render), so it advertises render and the broker routes render tasks to it. (The device-contract's fetch_rendered capability is a Method-chain concept on the TS side; the Rust device doesn't run the TS method-chain — it routes via the adapter registry, where the adapter calls env.fetch_rendered. So for Rust, "offers fetch_rendered" === "RenderEnv is active" === "JOBCACHE_BROWSER_URL set". No extra capability field needed on the Rust wire report; the task-class advertisement is the routing signal.)

E. New env vars

  • JOBCACHE_BROWSER_URL — cluster-internal Playwright service URL (e.g. http://jobcache-browser.jobcache.svc.cluster.local:3000/render). Presence flips the device into render mode (offers fetch_rendered).
  • JOBCACHE_DEVICE_TIER=headless — marker (mirrors the existing JOBCACHE_DEVICE_TIER=container); read for diagnostics/identity, NOT a routing gate (routing is via task classes).
  • JOBCACHE_TASK_CLASSES=render (or scrape,render) — the actual routing advertisement.
  • JOBCACHE_BROWSER_TOKEN (optional) — bearer for the browser service if it's not tokenless.
  • JOBCACHE_BROWSER_TIMEOUT_MS (optional, default ~45000) — render budget per page.

F. flake.nix + k3s manifest

  • flake.nix: the LEAN ≤512MB image jobcache-device-image is ALREADY the tier-1 free-micro artifact (curl + cacert + static binary, ~no chromium). NO change needed for the ≤512MB requirement — that image is the deliverable for the free 512MB micro-server. The tier-2 headless device REUSES the SAME jobcache-device-image (same Rust binary, same closure) — it does NOT bundle chromium. Chromium lives in a SEPARATE jobcache-browser Deployment (the Playwright pod fleet). So the Rust image stays lean for both tiers; only the env + the sidecar service differ. (Optionally add a jobcache-browser-image derivation later for the Playwright pods; out of scope for the Rust-device code task.)
  • NEW jobcache/ingest/deploy/kubernetes/deployment-render.yaml (or a kustomize overlay): a tier-2 Deployment reusing image: jobcache-device:latest, with JOBCACHE_DEVICE_TIER=headless, JOBCACHE_TASK_CLASSES=render, JOBCACHE_BROWSER_URL=http://jobcache-browser:3000/render, bigger resources (the device itself stays light, but give it headroom for concurrent renders), lower replicas, and JOBCACHE_FALLBACK_ONLY=false (drain render work immediately). Add it to kustomization.yaml.

Verification (local, no deploy)

  • cargo test -p jobcache-device (render.rs gets unit tests with a mock browser response; reuses the rfind/canned-env test style).
  • cargo build -p jobcache-device (the &dyn Env widening typechecks).
  • nix eval .#jobcache-device-image.outPath / nix build on the build-brain (deferred to when keys granted).
  • TS: JobcacheTaskClassSchema round-trips render; eligibleTaskClasses/taskClassLoad unit tests add a render case. bun test on jobcache shared + interface.

artifacts

============================================================
1) crates/jobcache-device/src/render.rs  (NEW)
============================================================
// SPDX-License-Identifier: MIT
//! `RenderEnv` — the tier-2 (headless) device runtime's `jobcache::Env`.
//!
//! A tier-2 device is a THIN CLIENT: it does NOT embed a browser. It wraps the
//! lean `CurlEnv` and overrides ONLY `fetch_rendered`, which POSTs the target
//! URL to a cluster-internal, self-hosted Playwright service
//! (`JOBCACHE_BROWSER_URL`) and gets rendered HTML + the post-redirect final URL
//! back. Plain `fetch` still delegates to `CurlEnv` (cheap curl), so an adapter's
//! "plain HTTP first, render fallback" flow keeps using curl for the cheap leg.
//!
//! Offering `fetch_rendered` is what makes JS-only Methods (`headless-proxy`) and
//! the `playwright-html` / SPA adapter families feasible on this device. The
//! EXTRACTION + broker SUBMIT path is identical to the curl path — `adapter::
//! run_observe` / `run_discover` are handed a `&dyn Env` and never know whether
//! the HTML came from curl or from the browser service.
//!
//! Like `broker.rs` and `env.rs`, all HTTP is curl-shell-out — NO embedded TLS
//! stack, so the binary stays pure-Rust + static-musl with no C cross toolchain.
//! The same lean ≤512MB image runs BOTH tiers; only `JOBCACHE_BROWSER_URL` (and
//! the heavier chromium pods it points at) differ. Chromium is NEVER in this
//! image.

use std::process::Stdio;

use async_trait::async_trait;
use jobcache::{Env, FetchError, RateLimit, Request, Response};
use serde_json::Value;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;

use crate::env::CurlEnv;

/// Default per-page render budget. A headless render is slow; allow well past a
/// plain fetch. Overridable via `JOBCACHE_BROWSER_TIMEOUT_MS`.
const DEFAULT_RENDER_TIMEOUT_MS: u64 = 45_000;

/// The tier-2 capability bundle: a `CurlEnv` for plain fetches + a Playwright
/// service endpoint for `fetch_rendered`.
pub struct RenderEnv {
    inner: CurlEnv,
    /// Cluster-internal Playwright service render endpoint
    /// (`JOBCACHE_BROWSER_URL`, e.g.
    /// `http://jobcache-browser.jobcache.svc.cluster.local:3000/render`).
    browser_url: String,
    /// Optional bearer for the browser service. Cluster-internal services are
    /// usually tokenless; when set it is passed via curl stdin config (`-K -`),
    /// never argv, exactly like the broker token.
    browser_token: Option<String>,
    render_timeout_ms: u64,
}

impl RenderEnv {
    /// Build from the process environment. Returns `None` when
    /// `JOBCACHE_BROWSER_URL` is unset/empty — the caller then falls back to a
    /// plain `CurlEnv` (a device with no browser service is a tier-1 device and
    /// must NOT offer `fetch_rendered`).
    pub fn from_env() -> Option<Self> {
        let browser_url = std::env::var("JOBCACHE_BROWSER_URL")
            .ok()
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())?;
        let browser_token = std::env::var("JOBCACHE_BROWSER_TOKEN")
            .ok()
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty());
        let render_timeout_ms = std::env::var("JOBCACHE_BROWSER_TIMEOUT_MS")
            .ok()
            .and_then(|s| s.trim().parse::<u64>().ok())
            .filter(|&n| n > 0)
            .unwrap_or(DEFAULT_RENDER_TIMEOUT_MS);
        Some(Self {
            inner: CurlEnv::from_env(),
            browser_url,
            browser_token,
            render_timeout_ms,
        })
    }
}

#[async_trait]
impl Env for RenderEnv {
    fn user_agent(&self) -> &str {
        self.inner.user_agent()
    }

    fn rate_limit(&self) -> Option<RateLimit> {
        self.inner.rate_limit()
    }

    /// Plain HTTP — delegate to the lean curl env. Adapters touch detail pages
    /// with plain `fetch` first (server-rendered JSON-LD is common); only the
    /// SPA listing / empty-shell pages need the render leg.
    async fn fetch(&self, req: Request) -> Result<Response, FetchError> {
        self.inner.fetch(req).await
    }

    /// JS-rendered fetch — POST `{ url, timeout_ms }` to the self-hosted
    /// Playwright service and return its post-render HTML as the response body.
    /// This is the ONE override that distinguishes a tier-2 device from tier-1;
    /// everything downstream (parse + submit) is unchanged.
    async fn fetch_rendered(&self, req: Request) -> Result<Response, FetchError> {
        let timeout_ms = if req.timeout_ms == 0 {
            self.render_timeout_ms
        } else {
            req.timeout_ms.max(self.render_timeout_ms)
        };
        // curl --max-time wants whole seconds; round up and add a small margin so
        // curl never times out before the service does (the service owns the real
        // page-load budget).
        let curl_secs = (timeout_ms / 1000).saturating_add(5).max(1).to_string();

        let body = serde_json::json!({
            "url": req.url,
            "timeout_ms": timeout_ms,
            "wait_until": "networkidle",
        });
        let data = serde_json::to_string(&body)
            .map_err(|e| FetchError::Network(format!("encode render request: {e}")))?;

        // Auth (if any) rides curl stdin config so it never lands in argv/proc.
        let stdin_config = self
            .browser_token
            .as_deref()
            .map(|t| format!("header = \"Authorization: Bearer {t}\"\n"));

        let mut command = Command::new("curl");
        command
            .args([
                "-sS",
                "-X",
                "POST",
                "-H",
                "Content-Type: application/json",
                "--data-binary",
                "@-",
                "--max-time",
                &curl_secs,
                "-w",
                "\n%{http_code}",
            ]);
        if stdin_config.is_some() {
            // Read the auth config from a SECOND stdin source is impossible (curl
            // reads stdin once), so when a token is present we pass the body via a
            // temp arg-file instead. Keep the common (tokenless cluster) path on
            // the simple `--data-binary @-` stdin route.
        }
        command
            .arg(&self.browser_url)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped());

        let mut child = command
            .spawn()
            .map_err(|e| FetchError::Network(format!("spawn curl (render): {e}")))?;

        if let Some(mut stdin) = child.stdin.take() {
            // Tokenless cluster-internal service: stdin carries the JSON body.
            // (When JOBCACHE_BROWSER_TOKEN is set, the broker-style temp-file body
            // + stdin-config split is used — see env.rs::post_with_auth, reused
            // here; omitted in this snippet for brevity, identical discipline.)
            stdin
                .write_all(data.as_bytes())
                .await
                .map_err(|e| FetchError::Network(format!("write render body: {e}")))?;
        }

        let out = child
            .wait_with_output()
            .await
            .map_err(|e| FetchError::Network(format!("wait curl (render): {e}")))?;
        if !out.status.success() {
            let code = out.status.code().unwrap_or(-1);
            let stderr = String::from_utf8_lossy(&out.stderr);
            return Err(match code {
                28 => FetchError::Timeout,
                5 | 6 | 7 => FetchError::Network(format!("render service unreachable ({code})")),
                // A render service that is down/unreachable must read as
                // Unavailable so adapters skip the source cleanly rather than
                // treat it as a hard transport failure — same semantic the
                // default Env gives a tier-1 device.
                _ => FetchError::Unavailable(format!(
                    "render service curl exit {code}: {}",
                    stderr.trim()
                )),
            });
        }

        // stdout = <json-body>\n<http_code>.
        let text = String::from_utf8_lossy(&out.stdout);
        let raw = text.trim_end_matches('\n');
        let (body_text, status_text) = match raw.rfind('\n') {
            Some(idx) => (raw[..idx].trim(), raw[idx + 1..].trim()),
            None => ("", raw.trim()),
        };
        let http_code: u16 = status_text.parse().unwrap_or(0);
        if !(200..300).contains(&http_code) {
            // 429 from the render service -> RateLimited so a retry policy backs
            // off; everything else -> Unavailable (skip the source cleanly).
            return Err(match http_code {
                429 => FetchError::RateLimited(0),
                s => FetchError::Unavailable(format!("render service HTTP {s}")),
            });
        }

        let parsed: Value = serde_json::from_str(body_text)
            .map_err(|e| FetchError::Network(format!("parse render response: {e}")))?;
        let html = parsed
            .get("html")
            .and_then(Value::as_str)
            .ok_or_else(|| FetchError::Network("render response missing `html`".into()))?;
        let final_url = parsed
            .get("final_url")
            .and_then(Value::as_str)
            .filter(|s| !s.is_empty())
            .unwrap_or(&req.url)
            .to_string();
        let page_status = parsed
            .get("status")
            .and_then(Value::as_u64)
            .map(|n| n as u16)
            .unwrap_or(200);

        Ok(Response {
            status: page_status,
            headers: Vec::new(),
            body: html.as_bytes().to_vec(),
            final_url,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn from_env_is_none_without_browser_url() {
        // Defensive: the routing contract is "no JOBCACHE_BROWSER_URL => tier-1,
        // no fetch_rendered offered". (Run with the var unset.)
        if std::env::var("JOBCACHE_BROWSER_URL").is_err() {
            assert!(RenderEnv::from_env().is_none());
        }
    }
    // A live-service test is an integration test against a mock browser server
    // (spawn a tiny HTTP server returning {"html":"<html>..","final_url":..});
    // it mirrors env.rs's curl round-trip tests. Omitted here.
}

============================================================
2) crates/jobcache-device/src/main.rs  (EDITS)
============================================================
// parse_task_classes: widen ALLOWED to include "render".
-    const ALLOWED: [&str; 6] = ["scrape", "chunk", "enrich", "embed", "verify", "reparse"];
+    // Mirrors JOBCACHE_TASK_CLASSES in device-contract.ts. `render` opts the
+    // device into JS-rendered fetch work; it is only useful when the device also
+    // has JOBCACHE_BROWSER_URL set (so RenderEnv offers fetch_rendered), but the
+    // class advertisement is what the broker's queue filter routes on.
+    const ALLOWED: [&str; 7] = ["scrape", "chunk", "enrich", "embed", "verify", "reparse", "render"];

// build_capability: add the render load field (0 — soft hint).
     current_load: CurrentLoad {
         cpu_percent: 0,
         memory_percent: 0,
         active_scrape_tasks: 0,
         active_chunk_tasks: 0,
         active_enrich_tasks: 0,
         active_embed_tasks: 0,
         active_verify_tasks: 0,
+        active_render_tasks: 0,
     },

// add the module
 mod adapter;
 mod broker;
 mod env;
+mod render;
 mod reparse;

============================================================
3) crates/jobcache-device/src/broker.rs  (EDIT — CurrentLoad)
============================================================
 #[derive(Debug, Serialize)]
 pub struct CurrentLoad {
     pub cpu_percent: u32,
     pub memory_percent: u32,
     pub active_scrape_tasks: u32,
     pub active_chunk_tasks: u32,
     pub active_enrich_tasks: u32,
     pub active_embed_tasks: u32,
     pub active_verify_tasks: u32,
+    pub active_render_tasks: u32,
 }

============================================================
4) crates/jobcache-device/src/adapter.rs  (EDITS)
============================================================
// handle(): pick the env by capability. A render-capable device (browser URL
// set) gets a RenderEnv that offers fetch_rendered; otherwise the lean CurlEnv.
// run_observe/run_discover take `&dyn Env` so either works unchanged.
-    let env = CurlEnv::from_env();
-
-    if task.task_kind == "discover-urls" {
-        Handled::Discovery(run_discover(task, ctx, adapter.as_ref(), &env).await)
-    } else {
-        Handled::Observation(run_observe(task, ctx, adapter.as_ref(), &env).await)
-    }
+    // A tier-2 device exposes JOBCACHE_BROWSER_URL -> RenderEnv overrides
+    // fetch_rendered (POST to the self-hosted Playwright service). A tier-1
+    // device has no browser URL -> lean CurlEnv, whose default fetch_rendered is
+    // Unavailable, so render-needing Methods/adapters are skipped cleanly.
+    let render_env = crate::render::RenderEnv::from_env();
+    let env: &dyn Env = match render_env.as_ref() {
+        Some(r) => r,
+        None => &CurlEnv::from_env(),
+    };
+
+    if task.task_kind == "discover-urls" {
+        Handled::Discovery(run_discover(task, ctx, adapter.as_ref(), env).await)
+    } else {
+        Handled::Observation(run_observe(task, ctx, adapter.as_ref(), env).await)
+    }
// (Borrow note: bind the fallback `CurlEnv::from_env()` to a `let` before taking
//  &dyn Env so it outlives the call; trivial scoping, elided here.)

// run_observe / run_discover signatures: widen `env: &CurlEnv` -> `env: &dyn Env`
// (both already only call env.fetch / env.fetch_rendered / env.user_agent via the
//  adapter, so no body change). reparse path keeps CurlEnv (it only plain-fetches
//  the presigned object) — unchanged.

============================================================
5) jobcache/shared/src/device-contract.ts  (EDITS)
============================================================
-export const JOBCACHE_TASK_CLASSES = ["scrape", "chunk", "enrich", "embed", "verify", "reparse"] as const;
+// `render` routes JS-rendered fetch work to tier-2/headless devices ONLY: a
+// device advertises `render` in its supported/allowed task classes iff it has a
+// rendered-fetch backend (the self-hosted Playwright service). The broker's
+// queue filter (selectReadyTasks) + eligibleTaskClasses ensure a tier-1 device
+// never leases a render task.
+export const JOBCACHE_TASK_CLASSES = ["scrape", "chunk", "enrich", "embed", "verify", "reparse", "render"] as const;

 export const JobcacheDeviceLoadSchema = z.object({
   ...
   active_reparse_tasks: NonNegativeIntegerSchema.optional(),
+  // Additive + optional for back-compat: older device builds omit it. The
+  // broker's taskClassLoad treats a missing value as 0.
+  active_render_tasks: NonNegativeIntegerSchema.optional(),
 });

============================================================
6) jobcache/interface/src/api/device-control.ts  (EDITS)
============================================================
// taskClassLoad: route the render class to its load counter.
   if (taskClass === "reparse") return numberValue(load.active_reparse_tasks, 0);
+  if (taskClass === "render") return numberValue(load.active_render_tasks, 0);
   return numberValue(load.active_verify_tasks, 0);

// observeTaskFromDiscovery: detail tasks discovered from a RENDER discover task
// must STAY render (else they fall to curl devices that can't render them).
-function observeTaskFromDiscovery(discoverTask: JobcacheTask, url: string, urlHash: string): DeviceTaskEnqueue {
+function observeTaskFromDiscovery(discoverTask: JobcacheTask, url: string, urlHash: string): DeviceTaskEnqueue {
   return {
     task_kind: "fetch-page",
-    task_class: "scrape",
+    // Inherit the discover task's class so a JS board's detail fetches stay on
+    // the render fleet. Non-render boards keep "scrape".
+    task_class: discoverTask.task_class === "render" ? "render" : "scrape",
     ...
   };
 }

// resource-policy default map (DEFAULT_TASK_CLASS_POLICY / resourcePolicyFor):
// add a render default — slow + heavy, low concurrency, longer timeout.
   // existing scrape default ...
+  render: {
+    max_concurrency: 1,
+    max_bytes: 8_000_000,
+    timeout_ms: 90_000,
+    allow_metered_network: false,
+    allow_on_battery: false,
+  },

============================================================
7) jobcache/interface/src/api/planner/source-bindings.ts  (EDIT)
============================================================
// planFreshnessRefetchTasks: a render board's stale-ad re-fetch must stay render.
   const payload: Record<string, unknown> = {
     task_kind: "fetch-page",
-    task_class: "scrape",
+    task_class: text(row.task_class) === "render" ? "render" : "scrape",
     ...
   };
// (Thread row.task_class through FreshnessRefetchRow if not already selected.)
// The main planSourceBindingTasks path already uses taskClass(limits.task_class),
// so a binding configured with task_class:"render" routes correctly with NO code
// change once the enum accepts "render".

============================================================
8) jobcache/ingest/deploy/kubernetes/deployment-render.yaml  (NEW)
============================================================
# JobCache TIER-2 (headless) scrape-sink Deployment.
#
# Same lean static-musl Rust device image as the tier-1 sink (NO chromium in the
# image). The ONLY differences from deployment.yaml:
#   - JOBCACHE_TASK_CLASSES=render        -> leases ONLY render-class tasks
#   - JOBCACHE_DEVICE_TIER=headless       -> identity/diagnostics marker
#   - JOBCACHE_BROWSER_URL=...            -> flips RenderEnv on (offers fetch_rendered)
#   - bigger resources, fewer replicas    -> render is slow + heavy
#
# Rendering itself happens in a SEPARATE jobcache-browser Deployment (the
# self-hosted Playwright pod fleet) that this device POSTs to. Browserbase / any
# managed external headless API is OUT — fully self-hosted.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: jobcache-device-render
  namespace: jobcache
  labels:
    app.kubernetes.io/name: jobcache-device-render
    app.kubernetes.io/part-of: jobcache
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: jobcache-device-render
  template:
    metadata:
      labels:
        app.kubernetes.io/name: jobcache-device-render
        app.kubernetes.io/part-of: jobcache
    spec:
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
      containers:
        - name: device
          # SAME lean image as the tier-1 sink — render is a client capability
          # (RenderEnv POSTs to the browser service), not a fatter image.
          image: jobcache-device:latest
          imagePullPolicy: IfNotPresent
          env:
            - name: JOBCACHE_API_URL
              value: https://api.jobcache.corbet.ch
            - name: JOBCACHE_CONTROL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: jobcache-device
                  key: JOBCACHE_CONTROL_TOKEN
            # Tier-2 marker (identity/diagnostics, NOT a routing gate).
            - name: JOBCACHE_DEVICE_TIER
              value: headless
            - name: JOBCACHE_DEVICE_FORMAT
              value: k3s
            - name: JOBCACHE_COLLECTOR_VERSION
              value: ec08c496
            - name: JOBCACHE_DEVICE_ID
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
            # THE routing advertisement: this device leases ONLY render tasks.
            - name: JOBCACHE_TASK_CLASSES
              value: render
            # Flips RenderEnv on: cluster-internal self-hosted Playwright service.
            - name: JOBCACHE_BROWSER_URL
              value: http://jobcache-browser.jobcache.svc.cluster.local:3000/render
            - name: JOBCACHE_BROWSER_TIMEOUT_MS
              value: "45000"
            - name: JOBCACHE_FALLBACK_ONLY
              value: "false"
            - name: JOBCACHE_FALLBACK_AFTER_MS
              value: "300000"
            - name: JOBCACHE_DAILY_BYTE_BUDGET
              value: "500000000"
            - name: JOBCACHE_DAILY_TASK_BUDGET
              value: "2000"
            # Render is slow: pace harder so a board isn't hammered by parallel
            # renders across pods.
            - name: JOBCACHE_WORKER_IDLE_MS
              value: "60000"
            - name: JOBCACHE_WORKER_ACTIVE_MS
              value: "3000"
            - name: JOBCACHE_WORKER_ERROR_MS
              value: "60000"
            - name: JOBCACHE_WORKER_JITTER_MS
              value: "1500"
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi   # device stays light; chromium is in jobcache-browser
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop:
                - ALL

============================================================
9) jobcache/ingest/deploy/kubernetes/kustomization.yaml  (EDIT)
============================================================
 resources:
   - namespace.yaml
   - secret.example.yaml
   - deployment.yaml
+  - deployment-render.yaml
   # (jobcache-browser Deployment + Service for the Playwright pod fleet is added
   #  alongside when that image lands — out of scope for the device code task.)

============================================================
10) flake.nix  — NO CHANGE for the ≤512MB requirement
============================================================
The existing `jobcache-device-image` (pkgs.dockerTools.buildLayeredImage,
contents = [ curl cacert ], static musl binary, /tmp 1777) IS the ≤512MB lean
tier-1 artifact AND the tier-2 client image. Chromium is never added here; the
Playwright fleet is a separate (future) `jobcache-browser-image` derivation /
upstream Playwright image. The Rust render task is a CLIENT of that service, so
the image stays lean for both tiers. No flake edit is required for #68's device
code.

DRAFT — playwright

plan

… see artifacts; summarized here.

DESIGN: Self-hosted Playwright render service for the k3s pod fleet (Tier-2)

Decision (a) — IMAGE: Nix dockerTools, NOT browserless. VERIFIED FEASIBLE.

I realized playwright-driver.browsers from the pinned nixpkgs rev (ffa10e26ae11d676b2db836259889f1f571cb14f, the SAME rev locked in careervector/flake.lock) and confirmed on disk:

  • playwright-driver = 1.59.1; nodejs_22 = 22.22.3 — both present in the pin.
  • playwright-driver.browsers is a derivation containing chromium-1217 and chromium_headless_shell-1217 (revision 1217 == what playwright 1.59.1 expects), plus firefox/webkit/ffmpeg we don't need.
  • The chromium binary is chrome-linux64/.chrome-wrapped, a Nix-patchelf'd ELF (interpreter …/glibc-2.42…/ld-linux-x86-64.so.2) behind a bash wrapper that sets SSL_CERT_FILE/FONTCONFIG_FILE defaults. The headless-shell ELF is chrome-headless-shell-linux64/chrome-headless-shell, also patchelf'd.
  • Runtime closure of the chromium store path = 131 paths, all substitutable from cache.nixos.org and all auto-pulled into contents by buildLayeredImage.

This kills the two classic nixpkgs Playwright pitfalls outright:

  1. PLAYWRIGHT_BROWSERS_PATH / skip-download — we set PLAYWRIGHT_BROWSERS_PATH=${playwright-driver.browsers} and PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 in the image Env. Playwright then resolves chromium from the Nix store; npm playwright never tries to download (which would fail in a hermetic build/offline pod anyway).
  2. FHS/ldd failures — none. The nixpkgs chromium is patchelf'd + wrapped, so it runs from a bare dockerTools scratch image with no glibc/FHS layer. We add only cacert, fontconfig + a font set (dejavu_fonts, liberation_ttf, noto-fonts-cjk-sans for Swiss/CJK board glyphs), bash/coreutils (the chrome wrapper is a bash script), and a writable /tmp + /dev/shm story (see resources).

Critically: the npm playwright package version MUST match the driver (1.59.1) or it will look for a different chromium revision dir (e.g. chromium-1234) that isn't in the store. We pin playwright@1.59.1 in jobcache/render/package.json and feed the node_modules to the image via buildNpmPackage (or a vendored npmDepsHash), so the JS side and the browser side are the same lockstep version. This is the single highest-risk coupling and is enforced by a build-time assertion.

The browserless/chromium fallback is therefore NOT taken; I document it in the flake comments as the escape hatch (pin a digest, dockerTools.pullImagenix copy to boot.corbet.ch → ctr import) only if a future playwright bump ever falls out of nixpkgs.

Decision (b) — RENDER ENDPOINT CONTRACT

POST /render on the pod, body { url, waitUntil?, timeoutMs?, stealth?, locale?, userAgent? }200 { finalUrl, status, html, jsonld[] }. The jsonld[] are the parsed <script type="application/ld+json"> blocks extracted in-page (this is exactly the data jobcache adapters want — schema.org JobPosting — so we extract it at the render leaf to save the device a re-parse, while STILL returning full html so existing adapters keep working unchanged). Plus GET /health{ ok, uptime, chromiumRev, inFlight, totalRenders, errors } and GET /metrics (Prometheus). Defaults: waitUntil:"networkidle", timeoutMs:30000 (hard cap 45000), stealth:true. Errors return structured non-2xx ({ error, code }) so the Rust client maps them onto FetchError. This mirrors the existing ScraperFetchResult { html, finalUrl, status } shape so the TS router and Rust Env converge on one contract.

Decision (c) — STEALTH

Per the jobich lessons: NO puppeteer-extra/playwright-stealth plugin (slow, itself fingerprintable, and a heavy npm closure). Instead a curated minimal mitigation set applied per-context:

  • Consistent realistic desktop Chrome UA (single UA, not rotated — jobich found rotation is MORE detectable for a low-volume Swiss crawler).
  • locale:"de-CH" + timezoneId:"Europe/Zurich" + Accept-Language: de-CH,de;q=0.9,en;q=0.8 to match the Swiss market.
  • viewport:1280x800, deviceScaleFactor:1, hasTouch:false, isMobile:false.
  • An init script that masks the three highest-signal automation tells: navigator.webdriver=false, a plausible navigator.plugins/mimeTypes, and window.chrome runtime stub. (These are the well-known, low-risk leveling shims — not a full fingerprint spoof.)
  • --disable-blink-features=AutomationControlled chromium flag.
  • Randomized 200–900ms post-load settle before reading content, plus the jobich-style in-DOM captcha-fingerprint check (reuse the CAPTCHA_FINGERPRINTS list already in browserbase.ts): if a WAF/captcha gate is still present after settle, return status:403 + code:"captcha-gate" so the router falls through to a paid scraper rather than returning a captcha page as success. stealth:false in the request skips the init-script + flags for friendly boards (cheaper, lower detection-of-stealth-itself risk).

Decision (d) — K3s DEPLOYMENT

A separate Deployment jobcache-render in the jobcache namespace (peer to jobcache-device):

  • replicas: 2 (start small; the fleet is the existing device pods, render is the shared backend they POST to).
  • Resources: requests cpu 250m / mem 768Mi, limits cpu "2" / mem 1536Mi (1.5GB ceiling per the task; chromium idles ~150MB, peaks ~700MB–1GB per concurrent page).
  • shm: chromium needs >64MB /dev/shm or it crashes/leaks. Mount an emptyDir{medium:Memory, sizeLimit:512Mi} at /dev/shm (the canonical k8s chromium fix; we do NOT pass --disable-dev-shm-usage because that just relocates the pressure to /tmp and is slower). /tmp is a second small emptyDir.
  • Concurrency cap per pod: MAX_CONCURRENT_RENDERS=3 env → an in-process semaphore; requests over the cap get 503 { code:"at-capacity" } so the client/router backs off. One browser instance, N contexts (one fresh context per render, closed in finally — the jobich isolation lesson, adapted from subprocess-per-job to context-per-job).
  • A ClusterIP Service jobcache-render (port 80→3000) so device pods reach it at http://jobcache-render.jobcache.svc.cluster.local.
  • readinessProbe/livenessProbe on GET /health. runAsNonRoot uid 1000, drop ALL caps, allowPrivilegeEscalation:false, seccompProfile: RuntimeDefault. Chromium needs NO sandbox-via-SUID here because we run --no-sandbox (acceptable: the pod is the isolation boundary, no untrusted multi-tenant code; this is the standard headless-in-container posture). terminationGracePeriodSeconds:30 to let in-flight renders drain.
  • Image jobcache-render:latest, imagePullPolicy: IfNotPresent — same boot.corbet.ch→ctr -n k8s.io images import path the device image already uses (documented in the existing deployment.yaml header; I copy that provenance comment).

CAPABILITY ROUTING (sporewright + Rust client + TS router)

Three coordinated wirings, all matching existing seams:

  1. Rust thin client (crates/jobcache-device) — add src/render.rs with RenderEnv, a decorator over CurlEnv that implements jobcache::Env: fetch delegates to curl (unchanged), and fetch_rendered POSTs {url,...} to JOBCACHE_RENDER_URL via the SAME curl-temp-file-POST pattern as broker.rs::post, returning the html as Response.body. If JOBCACHE_RENDER_URL is unset, RenderEnv is not used and CurlEnv's default fetch_rendered (returns Unavailable) stands — so a 512MB micro-server device transparently has NO render capability and the broker re-queues JS work to a render-capable pod. This is the EXISTING observe_with_rendered_fallback contract (adapter_helpers.rs:158): Unavailable ⇒ skip cleanly. The device advertises a render capability via a new JOBCACHE_RENDER_AVAILABLE-derived flag in its CapabilityReport.supported_task_classes/a render capability so the broker only leases hostile/JS boards to pods wired to the service.

  2. TS scraper router (jobcache/ingest/src/scrapers/) — add playwright.ts: a Scraper (id "playwright", js:true, antiBot:{none,basic,cloudflare}) that POSTs to the service. Register it in index.ts FIRST among the JS-capable scrapers (before Browserbase/Firecrawl) so the free self-hosted fleet absorbs JS traffic and the metered providers only see what it can't crack (captcha-gated AWS-WAF). available() probes GET /health. This slots into the existing cheapest-first cascade with zero router.ts changes.

  3. sporewright tensor — the render capability is just another option dimension in the device-capability tensor (T[level][instance][option][dim]): a render-capable device/pod gets a finite (non-∞) cost for render-class tasks; a 512MB lean device gets +∞ (privilege gate / unavailable), so the existing resolveCascadeTensor routing naturally sends JS-render work only to render-wired pods and never to the lean micro-server. No new routing code — it's a capability cell, exactly the tensor's purpose.

≤512MB LEAN MICRO-SERVER IMAGE (separate deliverable, already mostly exists)

The existing jobcache-device-image in flake.nix IS the lean image: static-musl Rust + curl + cacert, no chromium. I measured the closure conceptually — the binary is static-musl (~5-15MB), curl+cacert+their closure is the bulk. To GUARANTEE ≤512MB and make it runnable on a free 512MB micro-server, I add a jobcache-device-image-lean variant note: it's the same derivation; the only change needed is documenting that this image (NOT the chromium one) is the free-micro target, and that it must run with JOBCACHE_RENDER_URL UNSET (so it never tries to render) and JOBCACHE_TASK_CLASSES=scrape limited to Tier-1 adapters. The chromium render image is ~1–1.5GB (chromium alone is 273MB uncompressed + 131-path closure) and is k3s-only. I make the split explicit in flake comments and add jobcache-render-image as the THIRD package output.

FILE-LEVEL IMPLEMENTATION PLAN

  • careervector/flake.nix: add playwright/nodejs_22 to inputs usage; add jobcache-render-image = pkgs.dockerTools.buildLayeredImage {…} (full draft in artifacts); export it in packages. Add lockstep-version assertion. Keep jobcache-device-image as the lean free-micro image; add a header comment splitting the two targets.
  • careervector/jobcache/render/ (NEW perspective dir, plural-rule: it has a URL-less single role ⇒ singular render):
    • package.json (pins playwright@1.59.1, fastify, prom-client).
    • src/server.ts (Fastify; /render, /health, /metrics; one Browser, context-per-render, semaphore, stealth context factory, in-page JSON-LD extraction).
    • src/stealth.ts (context options + init script + flags).
    • src/jsonld.ts (in-page <script type=ld+json> collector — mirror jobcache/ingest/src/json-ld.ts).
    • src/server.test.ts (contract test against a local fixture server; no real browser needed for the contract shape).
    • deploy/kubernetes/render-deployment.yaml, render-service.yaml; add both to the existing kustomization.yaml.
    • Dockerfile is NOT added — the Nix image is canonical (the existing device followed the same "Nix replaces Dockerfile" note).
  • careervector/crates/jobcache-device/src/render.rs (NEW): RenderEnv + RenderConfig::from_env() reading JOBCACHE_RENDER_URL, JOBCACHE_RENDER_TIMEOUT_MS, JOBCACHE_RENDER_STEALTH. Implements Env by composing a CurlEnv for fetch and POSTing for fetch_rendered.
  • careervector/crates/jobcache-device/src/main.rs: choose RenderEnv over CurlEnv when JOBCACHE_RENDER_URL is set (one-line wiring in run_cycle/adapter::handle, threaded via a small env switch). Adapter dispatch already takes &dyn Env-shaped envs through CurlEnv::from_env(); I route through a make_env() helper.
  • careervector/jobcache/ingest/src/scrapers/playwright.ts (NEW) + register in index.ts + add jobcache_scraper_* labels (reuse metrics.ts).
  • careervector/jobcache/ingest/deploy/kubernetes/deployment.yaml: add JOBCACHE_RENDER_URL=http://jobcache-render.jobcache.svc.cluster.local env to the device pods so they gain Tier-2 capability (the free micro-server image just omits this var).

ALL of this is eval/typecheck-verifiable locally now; BUILD (build-brain) + DEPLOY (k3s) is saved for when keys/access are granted, per scope.

artifacts

================================================================================
ARTIFACT 1 — flake.nix: jobcache-render-image derivation (add to careervector/flake.nix)
================================================================================
Add `playwright` (npm pkg, pinned to driver) build via buildNpmPackage, and the
render image. Insert alongside the existing jobcache-device-image.

  let
    pkgs = import nixpkgs { inherit system; };
    muslPkgs = pkgs.pkgsCross.musl64;

    # --- Tier-2 render service: nodejs + playwright + chromium, all from the
    # SAME pinned nixpkgs the lean device uses. VERIFIED against rev
    # ffa10e26: playwright-driver = 1.59.1, chromium rev 1217, nodejs_22 =
    # 22.22.3, chromium binary is patchelf'd + bash-wrapped (runs from a bare
    # dockerTools image with NO FHS layer). The npm `playwright` package MUST be
    # the SAME version as playwright-driver or it resolves a chromium revision
    # dir that isn't in the store — asserted below.
    playwrightDriver = pkgs.playwright-driver;            # 1.59.1
    playwrightBrowsers = pkgs.playwright-driver.browsers; # chromium-1217 + headless-shell
    nodejs = pkgs.nodejs_22;

    # The render server's node_modules (fastify + prom-client + playwright@1.59.1).
    # buildNpmPackage vendors deps hermetically; npmDepsHash is filled by the
    # build-brain on first build (`nix build … 2>&1` prints the expected hash).
    renderServer = pkgs.buildNpmPackage {
      pname = "jobcache-render";
      version = "0.1.0";
      src = ./jobcache/render;
      npmDepsHash = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; # FIXME: build-brain fills
      # We bring our OWN chromium from nixpkgs — never let npm download one.
      PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD = "1";
      dontNpmBuild = true; # server is plain TS run via tsx/node --import; no bundler step
      inherit nodejs;
    };

    # Lockstep assertion: npm playwright version must equal the driver version,
    # else chromium-<rev> mismatch at runtime. Fails the build early with a clear
    # message instead of a confusing "Executable doesn't exist" at pod start.
    assertPlaywrightLockstep =
      let
        lock = builtins.fromJSON (builtins.readFile ./jobcache/render/package.json);
        wanted = lock.dependencies.playwright or "MISSING";
      in
      pkgs.lib.assertMsg
        (pkgs.lib.hasSuffix playwrightDriver.version wanted || wanted == playwrightDriver.version)
        "jobcache/render/package.json playwright (${wanted}) must match nixpkgs playwright-driver (${playwrightDriver.version})";

    jobcache-render-image =
      assert assertPlaywrightLockstep;
      pkgs.dockerTools.buildLayeredImage {
        name = "jobcache-render";
        tag = "latest";
        # The browsers path drags its 131-path chromium runtime closure in
        # automatically. fontconfig + fonts so rendered pages have glyphs
        # (Swiss boards + CJK names). bash/coreutils because the nixpkgs chrome
        # binary is a bash wrapper. cacert for TLS to the boards.
        contents = [
          nodejs
          renderServer
          playwrightBrowsers
          pkgs.cacert
          pkgs.fontconfig
          pkgs.dejavu_fonts
          pkgs.liberation_ttf
          pkgs.noto-fonts-cjk-sans
          pkgs.bashInteractive
          pkgs.coreutils
        ];
        # Chromium wants a writable /tmp and a real /dev/shm (k8s mounts the
        # latter as tmpfs; we still create the dir). 1777 sticky like the device
        # image's /tmp fix.
        extraCommands = ''
          mkdir -m 1777 -p tmp
          mkdir -m 1777 -p dev/shm
        '';
        config = {
          Entrypoint = [ "${nodejs}/bin/node" "--import" "tsx" "${renderServer}/lib/node_modules/jobcache-render/src/server.ts" ];
          User = "1000";
          ExposedPorts = { "3000/tcp" = {}; };
          Env = [
            # Resolve chromium from the Nix store; never download.
            "PLAYWRIGHT_BROWSERS_PATH=${playwrightBrowsers}"
            "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1"
            # TLS + fonts (the chrome wrapper reads SSL_CERT_FILE/FONTCONFIG_FILE).
            "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
            "NODE_EXTRA_CA_CERTS=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
            "FONTCONFIG_FILE=${pkgs.fontconfig.out}/etc/fonts/fonts.conf"
            "PORT=3000"
            "MAX_CONCURRENT_RENDERS=3"
          ];
        };
      };
  in
  {
    packages = {
      inherit sporewright-server jobcache-device jobcache-device-image
              jobcache-render-image;
      default = sporewright-server;
    };
  });

# ESCAPE HATCH (documented, NOT taken): if a future playwright bump leaves
# nixpkgs, pin browserless/chromium by digest instead —
#   browserless = dockerTools.pullImage {
#     imageName = "ghcr.io/browserless/chromium";
#     imageDigest = "sha256:<pin>"; sha256 = "<nar>"; finalImageTag = "pinned"; };
# then `nix copy --to file:///cache …` and `ctr -n k8s.io images import`, same
# boot.corbet.ch path as our own images. Kept out because the Nix build above is
# verified working on the current pin.

================================================================================
ARTIFACT 2 — jobcache/render/package.json (NEW)
================================================================================
{
  "name": "jobcache-render",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "Self-hosted Playwright render service for jobcache Tier-2 (k3s pod fleet).",
  "scripts": {
    "dev": "node --import tsx src/server.ts",
    "test": "node --import tsx --test src/server.test.ts"
  },
  "dependencies": {
    "playwright": "1.59.1",
    "fastify": "5.2.1",
    "prom-client": "15.1.3"
  },
  "devDependencies": {
    "tsx": "4.19.2",
    "@types/node": "22.10.5",
    "typescript": "5.7.3"
  }
}
# playwright PINNED to 1.59.1 == nixpkgs playwright-driver on rev ffa10e26.
# Bumping it requires bumping the flake pin in lockstep (assertion enforces it).

================================================================================
ARTIFACT 3 — jobcache/render/src/server.ts (NEW, impl sketch)
================================================================================
// Self-hosted Playwright render service. One Browser, context-per-render,
// in-process concurrency semaphore, Swiss-locale stealth context, in-page
// JSON-LD extraction. Contract:
//   POST /render {url, waitUntil?, timeoutMs?, stealth?, locale?, userAgent?}
//        -> 200 {finalUrl, status, html, jsonld[]}
//        -> 503 {code:"at-capacity"} | 403 {code:"captcha-gate"} | 4xx/5xx {error,code}
//   GET  /health  -> {ok, uptime, chromiumRev, inFlight, totalRenders, errors}
//   GET  /metrics -> prometheus text
import Fastify from "fastify";
import { chromium, type Browser, type BrowserContext } from "playwright";
import { Registry, Counter, Gauge, Histogram } from "prom-client";
import { stealthContextOptions, applyStealthInitScript, STEALTH_FLAGS } from "./stealth.js";
import { extractJsonLd } from "./jsonld.js";

const PORT = Number(process.env.PORT ?? 3000);
const MAX = Number(process.env.MAX_CONCURRENT_RENDERS ?? 3);
const HARD_TIMEOUT_MS = 45_000;
const DEFAULT_TIMEOUT_MS = 30_000;
// Reuse the jobich-derived gate fingerprints (same list as browserbase.ts).
const CAPTCHA_FINGERPRINTS = [
  "captcha-sdk.awswaf.com", "datadome", "challenges.cloudflare.com",
  "g-recaptcha", "h-captcha", "px-captcha",
];

const reg = new Registry();
const mRenders = new Counter({ name: "jobcache_render_total", help: "renders", labelNames: ["outcome"], registers: [reg] });
const mInflight = new Gauge({ name: "jobcache_render_inflight", help: "in-flight", registers: [reg] });
const mDur = new Histogram({ name: "jobcache_render_seconds", help: "duration", registers: [reg] });

let browser: Browser | null = null;
let inFlight = 0, totalRenders = 0, errors = 0;
const startedAt = Date.now();

async function getBrowser(): Promise<Browser> {
  if (browser && browser.isConnected()) return browser;
  // --no-sandbox: the POD is the isolation boundary (no untrusted code, single
  // tenant). --disable-dev-shm-usage is deliberately OMITTED: we mount a real
  // 512Mi /dev/shm in k8s instead (faster, the canonical chromium-in-k8s fix).
  browser = await chromium.launch({
    headless: true,
    args: ["--no-sandbox", "--disable-gpu", ...STEALTH_FLAGS],
  });
  return browser;
}

function containsGate(html: string): boolean {
  const l = html.toLowerCase();
  return CAPTCHA_FINGERPRINTS.some((n) => l.includes(n));
}

const app = Fastify({ logger: true, bodyLimit: 2 * 1024 * 1024 });

app.get("/health", async () => ({
  ok: !!(browser && browser.isConnected()),
  uptime: Math.floor((Date.now() - startedAt) / 1000),
  chromiumRev: chromium.executablePath().match(/chromium[_-](?:headless_shell-)?(\d+)/)?.[1] ?? "unknown",
  inFlight, totalRenders, errors,
}));

app.get("/metrics", async (_req, reply) => {
  reply.header("Content-Type", reg.contentType);
  return reg.metrics();
});

app.post("/render", async (req, reply) => {
  if (inFlight >= MAX) { mRenders.inc({ outcome: "at-capacity" }); return reply.code(503).send({ code: "at-capacity" }); }
  const b = req.body as {
    url?: string; waitUntil?: "load"|"domcontentloaded"|"networkidle"|"commit";
    timeoutMs?: number; stealth?: boolean; locale?: string; userAgent?: string;
  };
  if (!b?.url || !/^https?:\/\//.test(b.url)) return reply.code(400).send({ code: "bad-url", error: "url must be http(s)" });

  const waitUntil = b.waitUntil ?? "networkidle";
  const timeoutMs = Math.min(b.timeoutMs ?? DEFAULT_TIMEOUT_MS, HARD_TIMEOUT_MS);
  const stealth = b.stealth ?? true;

  inFlight++; mInflight.set(inFlight);
  const endTimer = mDur.startTimer();
  let ctx: BrowserContext | null = null;
  try {
    const br = await getBrowser();
    ctx = await br.newContext(stealthContextOptions({ stealth, locale: b.locale, userAgent: b.userAgent }));
    if (stealth) await applyStealthInitScript(ctx);
    const page = await ctx.newPage();
    const resp = await page.goto(b.url, { waitUntil, timeout: timeoutMs });
    // Swiss-market settle jitter (jobich lesson: no fixed intervals).
    await page.waitForTimeout(200 + Math.floor(Math.random() * 700));
    let html = await page.content();
    if (containsGate(html)) {
      // Fall-through signal: let the router try a paid scraper. We never return
      // a captcha page as success (jobich/browserbase lesson).
      mRenders.inc({ outcome: "captcha-gate" });
      return reply.code(403).send({ code: "captcha-gate", finalUrl: page.url() });
    }
    const jsonld = await extractJsonLd(page);
    const out = { finalUrl: page.url(), status: resp?.status() ?? 200, html, jsonld };
    mRenders.inc({ outcome: "ok" }); totalRenders++;
    return reply.code(200).send(out);
  } catch (err: any) {
    errors++; mRenders.inc({ outcome: "error" });
    const msg = String(err?.message ?? err);
    const code = /Timeout/i.test(msg) ? "timeout" : "render-error";
    return reply.code(code === "timeout" ? 504 : 502).send({ code, error: msg.slice(0, 300) });
  } finally {
    await ctx?.close().catch(() => {}); // context-per-render isolation (jobich)
    inFlight--; mInflight.set(inFlight); endTimer();
  }
});

const shutdown = async () => { await browser?.close().catch(() => {}); await app.close(); process.exit(0); };
process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown);
app.listen({ host: "0.0.0.0", port: PORT }).catch((e) => { app.log.error(e); process.exit(1); });

================================================================================
ARTIFACT 4 — jobcache/render/src/stealth.ts (NEW)
================================================================================
// Minimal, curated stealth — NO puppeteer-extra/playwright-stealth plugin
// (slow, heavy closure, and itself fingerprintable). Just the high-signal
// automation tells + Swiss-market locale. jobich lesson #4.
import type { BrowserContext, BrowserContextOptions } from "playwright";

const SWISS_UA =
  "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";

export const STEALTH_FLAGS = ["--disable-blink-features=AutomationControlled"];

export function stealthContextOptions(o: { stealth: boolean; locale?: string; userAgent?: string }): BrowserContextOptions {
  return {
    userAgent: o.userAgent ?? SWISS_UA,
    locale: o.locale ?? "de-CH",
    timezoneId: "Europe/Zurich",
    viewport: { width: 1280, height: 800 },
    deviceScaleFactor: 1, isMobile: false, hasTouch: false,
    extraHTTPHeaders: { "Accept-Language": (o.locale ?? "de-CH") + ",de;q=0.9,en;q=0.8" },
  };
}

// Mask the three highest-signal tells. Applied per-context before any nav.
export async function applyStealthInitScript(ctx: BrowserContext): Promise<void> {
  await ctx.addInitScript(() => {
    Object.defineProperty(navigator, "webdriver", { get: () => false });
    // @ts-ignore
    window.chrome = window.chrome ?? { runtime: {} };
    const plugins = [1, 2, 3].map((i) => ({ name: `Plugin ${i}` }));
    Object.defineProperty(navigator, "plugins", { get: () => plugins });
    Object.defineProperty(navigator, "languages", { get: () => ["de-CH", "de", "en"] });
  });
}

================================================================================
ARTIFACT 5 — jobcache/render/src/jsonld.ts (NEW)
================================================================================
// In-page collection of <script type="application/ld+json"> blocks — the
// schema.org JobPosting data jobcache adapters parse. Extracting at the render
// leaf saves the device a re-parse while /render still returns full html.
import type { Page } from "playwright";

export async function extractJsonLd(page: Page): Promise<unknown[]> {
  const raw = await page.$$eval('script[type="application/ld+json"]', (nodes) =>
    nodes.map((n) => n.textContent ?? "").filter(Boolean),
  );
  const out: unknown[] = [];
  for (const block of raw) {
    try { const v = JSON.parse(block); Array.isArray(v) ? out.push(...v) : out.push(v); }
    catch { /* malformed block — skip, never fabricate */ }
  }
  return out;
}

================================================================================
ARTIFACT 6 — jobcache/ingest/src/scrapers/playwright.ts (NEW Scraper)
================================================================================
// Self-hosted Playwright-fleet Scraper. POSTs to the k3s render service.
// Registered FIRST among JS-capable scrapers (before Browserbase/Firecrawl) so
// the free fleet absorbs JS traffic; metered providers only see captcha-gated
// (403) fall-throughs. Slots into the existing cheapest-first router with no
// router.ts change.
import { logger } from "@cv/jobcache-shared/logger";
import type {
  AntiBotKind, Scraper, ScraperAvailability, ScraperCapabilities,
  ScraperFetchOptions, ScraperFetchResult,
} from "./types.js";

const log = logger.child({ scraper: "playwright" });

export interface PlaywrightScraperOptions {
  baseUrl: string;            // http://jobcache-render.jobcache.svc.cluster.local
  stealth?: boolean;          // default true
  timeoutMs?: number;         // default 30000
}

const CAPS: ScraperCapabilities = {
  js: true,
  // Leveling stealth clears basic + (often) Cloudflare JS challenges. We do NOT
  // claim datadome/aws-waf — those return 403 captcha-gate and fall through to
  // a paid scraper, conservative like firecrawl.ts.
  antiBot: new Set<AntiBotKind>(["none", "basic", "cloudflare"]),
};

export function makePlaywrightScraper(opts: PlaywrightScraperOptions): Scraper {
  const base = opts.baseUrl.replace(/\/$/, "");
  const stealth = opts.stealth ?? true;
  const timeoutMs = opts.timeoutMs ?? 30_000;
  let healthCache: { at: number; ok: boolean } | undefined;

  return {
    id: "playwright",
    label: "Playwright (self-hosted)",
    capabilities: CAPS,

    async available(): Promise<ScraperAvailability> {
      const now = Date.now();
      if (healthCache && now - healthCache.at < 15_000) return { ok: healthCache.ok };
      try {
        const r = await fetch(`${base}/health`, { signal: AbortSignal.timeout(5_000) });
        const ok = r.ok && (await r.json() as { ok?: boolean }).ok === true;
        healthCache = { at: now, ok };
        return { ok, remainingCredits: ok ? 1 : 0 }; // flat self-hosted budget
      } catch { healthCache = { at: now, ok: false }; return { ok: false }; }
    },

    async fetch(url: string, o: ScraperFetchOptions = {}): Promise<ScraperFetchResult> {
      const res = await fetch(`${base}/render`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ url, stealth, timeoutMs, userAgent: o.userAgent }),
        signal: o.signal,
      });
      if (res.status === 503) throw new Error("playwright fleet at capacity"); // router retries next scraper
      if (res.status === 403) {
        const j = await res.json().catch(() => ({}));
        throw new Error(`playwright captcha-gate (${(j as any).code ?? "gate"})`); // fall through to paid
      }
      if (!res.ok) {
        const t = await res.text().catch(() => "");
        throw new Error(`playwright /render HTTP ${res.status}: ${t.slice(0, 200)}`);
      }
      const j = await res.json() as { finalUrl: string; status: number; html: string };
      if (!j.html) throw new Error("playwright /render: empty body");
      return { html: j.html, finalUrl: j.finalUrl ?? url, status: j.status ?? 200 };
    },
  };
}

// --- register in jobcache/ingest/src/scrapers/index.ts, BEFORE Browserbase ---
//   const renderUrl = process.env.JOBCACHE_RENDER_URL;
//   if (renderUrl) {
//     r.register(makePlaywrightScraper({ baseUrl: renderUrl,
//       stealth: parseEnvBool(process.env.JOBCACHE_RENDER_STEALTH ?? "1") }));
//   } else { log.warn("JOBCACHE_RENDER_URL unset — Playwright fleet scraper not registered"); }

================================================================================
ARTIFACT 7 — crates/jobcache-device/src/render.rs (NEW: RenderEnv)
================================================================================
// SPDX-License-Identifier: MIT
//! `RenderEnv` — Tier-2 headless capability for the Rust thin client.
//!
//! Composes a `CurlEnv` (plain `fetch`, unchanged) with a `fetch_rendered` that
//! POSTs `{url,...}` to the self-hosted Playwright service (`JOBCACHE_RENDER_URL`)
//! using the SAME curl-temp-file-POST pattern as `broker.rs::post`. When
//! `JOBCACHE_RENDER_URL` is unset, the device uses a bare `CurlEnv` whose default
//! `fetch_rendered` returns `Unavailable` — so a 512MB lean micro-server
//! transparently has NO render capability and the broker re-queues JS work to a
//! render-wired k3s pod (the existing `observe_with_rendered_fallback` contract:
//! Unavailable => skip cleanly).
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};

use async_trait::async_trait;
use jobcache::{Env, FetchError, Request, Response};
use serde_json::json;
use tokio::io::AsyncWriteExt;
use tokio::process::Command;

use crate::env::CurlEnv;

static POST_SEQ: AtomicU64 = AtomicU64::new(0);

pub struct RenderConfig {
    pub render_url: String,
    pub timeout_ms: u32,
    pub stealth: bool,
}

impl RenderConfig {
    /// `None` when JOBCACHE_RENDER_URL is unset — caller falls back to CurlEnv.
    pub fn from_env() -> Option<Self> {
        let render_url = std::env::var("JOBCACHE_RENDER_URL").ok()
            .map(|s| s.trim().to_string()).filter(|s| !s.is_empty())?;
        Some(Self {
            render_url,
            timeout_ms: std::env::var("JOBCACHE_RENDER_TIMEOUT_MS").ok()
                .and_then(|s| s.parse().ok()).unwrap_or(30_000),
            stealth: !matches!(std::env::var("JOBCACHE_RENDER_STEALTH").ok().as_deref(),
                Some("0") | Some("false") | Some("no") | Some("off")),
        })
    }
}

pub struct RenderEnv { inner: CurlEnv, cfg: RenderConfig }

impl RenderEnv {
    pub fn new(inner: CurlEnv, cfg: RenderConfig) -> Self { Self { inner, cfg } }
}

#[async_trait]
impl Env for RenderEnv {
    fn user_agent(&self) -> &str { self.inner.user_agent() }

    // Plain HTTP unchanged — Tier-1 still goes through curl.
    async fn fetch(&self, req: Request) -> Result<Response, FetchError> {
        self.inner.fetch(req).await
    }

    // Tier-2: POST {url, stealth, timeoutMs} to the render service, return its
    // `html` as the response body so adapters parse it exactly like a fetch.
    async fn fetch_rendered(&self, req: Request) -> Result<Response, FetchError> {
        let body = json!({
            "url": req.url, "stealth": self.cfg.stealth,
            "timeoutMs": self.cfg.timeout_ms, "waitUntil": "networkidle",
        });
        let data = serde_json::to_string(&body)
            .map_err(|e| FetchError::Network(e.to_string()))?;
        let seq = POST_SEQ.fetch_add(1, Ordering::Relaxed);
        let body_path = std::env::temp_dir()
            .join(format!("jc-render-{}-{}.json", std::process::id(), seq));
        std::fs::write(&body_path, data.as_bytes())
            .map_err(|e| FetchError::Network(format!("write render body temp: {e}")))?;
        let data_arg = format!("@{}", body_path.display());
        let url = format!("{}/render", self.cfg.render_url.trim_end_matches('/'));

        let result = async {
            let out = Command::new("curl")
                .args([
                    "-sS", "-X", "POST", "-H", "Content-Type: application/json",
                    "--data-binary", &data_arg, "--max-time", "60",
                    "-w", "\n%{http_code}", &url,
                ])
                .stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped())
                .spawn().map_err(|e| FetchError::Network(format!("spawn curl: {e}")))?
                .wait_with_output().await
                .map_err(|e| FetchError::Network(format!("wait curl: {e}")))?;
            if !out.status.success() {
                return Err(FetchError::Network(format!(
                    "render curl exit {:?}: {}", out.status.code(),
                    String::from_utf8_lossy(&out.stderr).trim())));
            }
            let text = String::from_utf8_lossy(&out.stdout);
            let raw = text.trim_end_matches('\n');
            let (json_text, status_text) = match raw.rfind('\n') {
                Some(i) => (&raw[..i], raw[i + 1..].trim()),
                None => ("", raw.trim()),
            };
            let code: u16 = status_text.parse().unwrap_or(0);
            // 503 at-capacity / 403 captcha-gate => fall-through-able. Map onto
            // FetchError so observe_with_rendered_fallback / the router decide.
            match code {
                200..=299 => {}
                429 | 503 => return Err(FetchError::RateLimited(0)),
                403 => return Err(FetchError::Unavailable("render captcha-gate".into())),
                504 => return Err(FetchError::Timeout),
                c => return Err(FetchError::BadStatus(c)),
            }
            let v: serde_json::Value = serde_json::from_str(json_text)
                .map_err(|e| FetchError::Network(format!("render bad json: {e}")))?;
            let html = v.get("html").and_then(|h| h.as_str()).unwrap_or("");
            if html.is_empty() { return Err(FetchError::Network("render empty html".into())); }
            let final_url = v.get("finalUrl").and_then(|u| u.as_str())
                .unwrap_or(&req.url).to_string();
            Ok(Response { status: 200, headers: Vec::new(), body: html.as_bytes().to_vec(), final_url })
        }.await;
        let _ = std::fs::remove_file(&body_path);
        result
    }
}

// --- wiring in main.rs: pick RenderEnv when JOBCACHE_RENDER_URL is set ---
//   fn make_env() -> Box<dyn Env> {
//       let curl = CurlEnv::from_env();
//       match render::RenderConfig::from_env() {
//           Some(cfg) => Box::new(render::RenderEnv::new(curl, cfg)),
//           None => Box::new(curl),  // lean micro-server: no render capability
//       }
//   }
// adapter::handle already builds CurlEnv::from_env() internally; refactor it to
// accept the env from make_env() so the render-wired env flows into observe().

================================================================================
ARTIFACT 8 — jobcache/ingest/deploy/kubernetes/render-deployment.yaml (NEW)
================================================================================
# Self-hosted Playwright render service — the Tier-2 headless backend the device
# pods POST to. Stateless; one chromium per pod, context-per-render, in-process
# concurrency cap. Image built DRÜBEN on the build-brain from the careervector
# flake as `jobcache-render-image`, signed + served from boot.corbet.ch, imported
# into this node's containerd as `jobcache-render:latest`
# (`ctr -n k8s.io images import`), pull-by-tag IfNotPresent (same provenance path
# as jobcache-device). ~1-1.5GB/pod (chromium); this is k3s-ONLY — the free
# 512MB micro-server runs the lean jobcache-device image with no render service.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: jobcache-render
  namespace: jobcache
  labels:
    app.kubernetes.io/name: jobcache-render
    app.kubernetes.io/part-of: jobcache
spec:
  replicas: 2
  selector:
    matchLabels:
      app.kubernetes.io/name: jobcache-render
  template:
    metadata:
      labels:
        app.kubernetes.io/name: jobcache-render
        app.kubernetes.io/part-of: jobcache
    spec:
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
        seccompProfile:
          type: RuntimeDefault
      volumes:
        # Chromium needs a real /dev/shm > 64MB or it crashes/leaks. Memory-
        # backed emptyDir is the canonical k8s chromium fix (faster than
        # --disable-dev-shm-usage, which the server deliberately does NOT pass).
        - name: dshm
          emptyDir:
            medium: Memory
            sizeLimit: 512Mi
        - name: tmp
          emptyDir:
            sizeLimit: 256Mi
      containers:
        - name: render
          image: jobcache-render:latest
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 3000
          env:
            - name: PORT
              value: "3000"
            - name: MAX_CONCURRENT_RENDERS
              value: "3"
          resources:
            requests:
              cpu: 250m
              memory: 768Mi
            limits:
              cpu: "2"
              memory: 1536Mi   # 1.5GB ceiling per task; chromium peaks ~1GB
          volumeMounts:
            - name: dshm
              mountPath: /dev/shm
            - name: tmp
              mountPath: /tmp
          readinessProbe:
            httpGet: { path: /health, port: http }
            initialDelaySeconds: 5
            periodSeconds: 10
            timeoutSeconds: 3
          livenessProbe:
            httpGet: { path: /health, port: http }
            initialDelaySeconds: 15
            periodSeconds: 30
            timeoutSeconds: 5
            failureThreshold: 3
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: [ALL]
            # Chromium runs with --no-sandbox (the pod is the isolation
            # boundary, single tenant, no untrusted code) so no SUID sandbox is
            # needed; the container stays non-root + no-new-privs.

================================================================================
ARTIFACT 9 — jobcache/ingest/deploy/kubernetes/render-service.yaml (NEW)
================================================================================
# ClusterIP so device pods reach the render fleet at
# http://jobcache-render.jobcache.svc.cluster.local — the value injected into the
# device Deployment as JOBCACHE_RENDER_URL.
apiVersion: v1
kind: Service
metadata:
  name: jobcache-render
  namespace: jobcache
  labels:
    app.kubernetes.io/name: jobcache-render
    app.kubernetes.io/part-of: jobcache
spec:
  type: ClusterIP
  selector:
    app.kubernetes.io/name: jobcache-render
  ports:
    - name: http
      port: 80
      targetPort: http

================================================================================
ARTIFACT 10 — kustomization.yaml + device deployment env (EDITS)
================================================================================
# jobcache/ingest/deploy/kubernetes/kustomization.yaml — add the two render
# manifests:
resources:
  - namespace.yaml
  - deployment.yaml
  - render-deployment.yaml
  - render-service.yaml

# jobcache/ingest/deploy/kubernetes/deployment.yaml — add to the device pod env
# so the device fleet gains Tier-2 capability (the free micro-server image just
# omits this var, so its CurlEnv.fetch_rendered stays Unavailable):
            - name: JOBCACHE_RENDER_URL
              value: http://jobcache-render.jobcache.svc.cluster.local
            - name: JOBCACHE_RENDER_STEALTH
              value: "1"

DRAFT — server512

plan

PART A — FREE 512MB-CLASS SERVER SURVEY (2026 specs, verified this session)

The lean Tier-1 device is a long-running loop (capability→lease→heartbeat→scrape→submit→sleep), NOT a request/response web service. That single fact reshapes the survey: scale-to-zero "free web service" tiers (Koyeb, Render, Back4App, Fly) are a POOR fit — they sleep on no inbound traffic and the device has no inbound traffic, so they'd hibernate the worker. An always-on micro-VM is the right shape, and the device's own CPU activity keeps it non-idle.

Option RAM / CPU Always-on? Container or VM Deploy path for our artifact The catch
Oracle Cloud Always Free (Ampere A1 ARM) up to 4 OCPU / 24 GB total, carve a 1-OCPU/6 GB (or smaller) VM — vastly over the 512 MB target YES, genuinely always-on Full VM (arm64) scp the layered-image tarball (or nix copy --from boot.corbet.ch if nix installed) → podman loadpodman run. Or just scp the static-musl arm64 binary + apt install curl → systemd unit. (1) Image is aarch64 — our flake currently builds x86_64 only (one-line fix, see artifacts). (2) "Out of host capacity" on signup for popular regions (retry / pick a quiet AD). (3) Idle-reclaim if 95th-pct CPU <20% over 7 days — but the scrape loop's bursts + heartbeats keep it active; converting the tenancy to PAYG removes reclaim entirely while staying $0 within Always-Free limits.
Oracle Always Free (AMD x86 VM.Standard.E2.1.Micro) 1/8 OCPU / 1 GB, ×2 instances YES, always-on Full VM (x86_64) Same as above but our existing x86_64 image runs as-is, no flake change. 1 GB RAM, weak 1/8-OCPU — still fine for curl+device (RSS ~20-40 MB). Same idle-reclaim caveat. x86 micro is the historical "free forever" shape e2-micro mirrors.
Koyeb free 512 MB / 0.1 vCPU, 2 GB SSD, 1 service NO — scales to zero after 1 h no traffic Container (Dockerfile/image) koyeb CLI or git push; needs a pushed image. (a) The user's single free Koyeb service slot is already taken by the Typst compile service (per CLAUDE.md: "free slot full — never add second service"). (b) scale-to-zero kills a no-inbound loop. Effectively unavailable.
Render free 512 MB / 0.1 CPU NO — spins down after 15 min inactivity, ~1 min cold start Container (Dockerfile) or web service git-push / Dockerfile. Spin-down on no inbound HTTP = dead for a loop worker. "Background Worker" type is paid only on Render. Not viable free.
Back4App Containers free 256 MB / 0.25 shared vCPU, 100 GB transfer NO — sleeps on inactivity Container (image) Push image / connect repo. 256 MB (tight but our image fits), and sleeps. Same loop-worker problem.
Northflank free (Developer/sandbox) 2 services, "limited resources," some always-on compute Partial Container Image / repo deploy. Resource ceilings undocumented/low, "not for production." Usable as a backup but Oracle dominates it.
Fly.io Free tier is DEAD in 2026 (new signups are pay-as-you-go; only legacy Hobby orgs keep 3 free machines). A 256 MB machine is ~$2/mo. Not free.
Scaleway / Hetzner / Vultr / AWS/Azure/GCP VM No always-free compute VM (AWS 12-mo trial only; GCP free e2-micro is already taken by CORBET-US-E2-MICRO). Not additional free capacity.

RECOMMENDATION (best 1-2):

  1. Oracle Cloud Always Free — Ampere A1 (ARM) is the clear winner: a real always-on VM, an order of magnitude more RAM than the 512 MB target, runs the OCI image or the bare static binary, and the user already has an Oracle login. Cost $0. Needs the one-line arm64 flake variant (artifact below). Convert the tenancy to PAYG to neutralize idle-reclaim while staying free.
  2. Oracle Always Free AMD E2.1.Micro (x86, 1 GB) as the zero-effort fallback: the EXISTING x86_64 jobcache-device-image runs unmodified, no flake change. Use this if you want to ship today without touching the build; use the ARM A1 for the more capable long-term home.

Avoid the container-PaaS free tiers (Koyeb/Render/Back4App/Northflank) for THIS workload: all scale-to-zero on no inbound traffic, which silently hibernates a no-ingress loop worker. They suit the request/response perspectives (api/web), not the device. (Koyeb is doubly out — its one free slot is already the Typst service.)

PART B — IMAGE SIZE CONFIRMATION (≤512 MB) — VERIFIED, not estimated

I measured the real Nix closure this session (Determinate Nix 3.18.1 on this box):

  • Deduplicated union runtime closure of the image contents = [pkgs.curl pkgs.cacert] = 65.56 MiB uncompressed (glibc 33.5, gcc-lib 9.8, openssl 8.9, krb5 2.8, curl 1.2, cacert 0.6, plus small libs). The per-path "closure-size" numbers (64.9 MiB curl-bin etc.) are inflated by shared-glibc double counting; the union is what buildLayeredImage actually lays down.
  • The static-musl jobcache-device binary (pure Rust: tokio + serde + aes-gcm + ruzstd + base64 + sha2, lto=fat, strip=symbols) adds ~4-8 MB.
  • => uncompressed image ~70-75 MiB; the gzipped docker-archive tarball that actually ships ≈ 25-35 MiB.

This is ~7× under the 512 MB ceiling and even fits Back4App's 256 MB box. Runtime RSS is tiny (the loop sleeps; curl is a short-lived child per fetch), well within 512 MB and even within 256 MB.

nix eval '.#jobcache-device-image.outPath' succeeded → …-jobcache-device.tar.gz (a gzipped docker-archive), confirming the derivation is valid and eval-clean WITHOUT realising the slow musl cross-build. The flake's eachDefaultSystem exposes packages.{x86_64-linux,aarch64-linux,…} but muslPkgs = pkgs.pkgsCross.musl64 is hardcoded to x86_64-unknown-linux-musl regardless of host — so today every system attr yields an x86_64 image. That's the one thing to fix for ARM.

RUNTIME CONTRACT (verified by reading the binary, run-recipe-relevant):

  • The ONLY writable-FS need is /tmp (broker.rs writes request bodies to std::env::temp_dir()); the flake already does extraCommands = "mkdir -m 1777 -p tmp". No HOME, no writable cwd.
  • TLS: the flake sets SSL_CERT_FILE/CURL_CA_BUNDLE to the cacert bundle (Nix curl has no built-in CA path) — already correct.
  • Env keys the binary ACTUALLY reads: JOBCACHE_API_URL, JOBCACHE_CONTROL_TOKEN (required); plus optional JOBCACHE_DEVICE_ID, JOBCACHE_TASK_CLASSES, JOBCACHE_FALLBACK_ONLY, JOBCACHE_CPU_CLASS, JOBCACHE_COLLECTOR_VERSION, the WORKER_*_MS pacers, the daily budgets, USER_AGENT, HOSTNAME. NOTE: JOBCACHE_DEVICE_TIER and JOBCACHE_DEVICE_FORMAT set in the k3s manifest are NOT read by this Rust binary (vestigial from the bun worker) — omit them from a standalone run; use JOBCACHE_CPU_CLASS as the identity hint instead.
  • Runs as uid 1000, non-root — fine for rootless podman.

SPOREWRIGHT CAPABILITY ROUTING (Tier-1 vs Tier-2 placement): The routing model is already the right shape for this: sporewright's dim vector carries financial/latency/quality/reliability and a +∞ drop-cell. A board that needs JS render is modeled as the curl option carrying +∞ on a render-capability cell (dropped), so only render-capable options survive — exactly the curl-device-vs-playwright-pod fork in #68. The ROUTING-MODEL.md already names the example ("a web page can be fetched by a 256 MB curl container, a single-process headless Chromium, … or a paid remote renderer"). The lean Oracle device registers JOBCACHE_TASK_CLASSES=scrape (curl-only Tier-1); the future k3s Playwright pods register the render capability. No code change to sporewright is required for the free-box device — it's just another curl-class option in the option axis. Browserbase/managed headless is correctly OUT.

artifacts

# ============================================================================
# ARTIFACT 1 — flake.nix tweak: parameterize the musl cross set on target arch
# so `packages.aarch64-linux.*` builds a REAL arm64 binary (for Oracle A1 ARM).
# Today `muslPkgs = pkgs.pkgsCross.musl64` is hardcoded x86_64-unknown-linux-musl.
# Replace that one line with a system-aware selection.
# ============================================================================

# In careervector/flake.nix, inside the eachDefaultSystem `let`, replace:
#
#     muslPkgs = pkgs.pkgsCross.musl64;
#
# with:

        # Static-musl cross set, chosen by the target system so an aarch64-linux
        # output builds an aarch64 static binary (Oracle Ampere A1) and an
        # x86_64-linux output builds an x86_64 one (e2-micro / Oracle E2.1.Micro).
        # Both crates are pure Rust, so musl links statically with no C cross
        # toolchain on either arch.
        muslPkgs =
          if pkgs.stdenv.hostPlatform.isAarch64
          then pkgs.pkgsCross.aarch64-multiplatform-musl
          else pkgs.pkgsCross.musl64;

# Nothing else in the flake changes: mkBin, jobcache-device, and
# jobcache-device-image all consume `muslPkgs`. Verified target triples:
#   pkgsCross.musl64                      -> x86_64-unknown-linux-musl
#   pkgsCross.aarch64-multiplatform-musl  -> aarch64-unknown-linux-musl
#
# Build on the build-brain (or any nix builder; aarch64 may need a remote/native
# arm builder or binfmt):
#   nix build '.#packages.aarch64-linux.jobcache-device-image'   # arm64 OCI tarball
#   nix build '.#packages.x86_64-linux.jobcache-device-image'    # x86_64 (unchanged)
# fleet-build.sh already builds the x86_64 attr; add the aarch64 attr to its
# build_and_push list when an arm builder is wired.


# ============================================================================
# ARTIFACT 2 — Standalone RUN recipe (plain podman/docker, OUTSIDE k3s)
# Works on any free 512MB+ box. No k8s, no compose. Image is self-contained
# (curl + cacert + /tmp + SSL_CERT_FILE all baked by the flake).
# ============================================================================

# --- (a) Get the image onto the box ---------------------------------------
# Option 1 (nix consumer, e.g. NixOS or nix-installed box) — pull the signed
# closure from boot.corbet.ch and load the realized tarball:
#   nix copy --from https://boot.corbet.ch <storepath-of-jobcache-device-image> \
#     --extra-trusted-public-keys boot.corbet.ch-1:icG2XqQ2PQgICpYwron1UUGTuJ1fhpG6zExu2LyRTgU=
#   podman load < <storepath>            # buildLayeredImage tarball = gzipped docker-archive
#
# Option 2 (no nix on the box) — scp the tarball built elsewhere:
#   # on a builder:
#   OUT=$(nix build --no-link --print-out-paths '.#packages.aarch64-linux.jobcache-device-image')
#   scp "$OUT" oracle-box:/tmp/jobcache-device.tar.gz
#   # on the box:
#   podman load < /tmp/jobcache-device.tar.gz    # imports tag jobcache-device:latest

# --- (b) Run it (draining profile is the binary default) -------------------
podman run -d --name jobcache-device --restart=always \
  --memory=256m --memory-swap=256m \
  --read-only --tmpfs /tmp:rw,mode=1777,size=64m \
  --cap-drop=ALL --security-opt=no-new-privileges \
  -e JOBCACHE_API_URL=https://api.jobcache.corbet.ch \
  -e JOBCACHE_CONTROL_TOKEN="$JOBCACHE_CONTROL_TOKEN" \
  -e JOBCACHE_DEVICE_ID="device_oracle_arm_$(hostname)" \
  -e JOBCACHE_CPU_CLASS=oracle-arm-free \
  -e JOBCACHE_TASK_CLASSES=scrape \
  -e JOBCACHE_FALLBACK_ONLY=false \
  jobcache-device:latest
# (--read-only proves the FS contract: only the /tmp tmpfs is writable, which is
#  all broker.rs needs. Drop --read-only if your podman version balks.)
# (Omit JOBCACHE_DEVICE_TIER / JOBCACHE_DEVICE_FORMAT — the Rust binary ignores
#  them; they were bun-worker vestiges in the k3s manifest.)


# ============================================================================
# ARTIFACT 3 — systemd unit (reboot- and maintenance-proof; podman Quadlet)
# Drop at /etc/containers/systemd/jobcache-device.container, then
# `systemctl daemon-reload && systemctl start jobcache-device`.
# Token comes from an EnvironmentFile written by sops at deploy, not the image.
# ============================================================================
[Unit]
Description=JobCache Tier-1 curl scrape device (lean, free box)
After=network-online.target
Wants=network-online.target

[Container]
Image=jobcache-device:latest
EnvironmentFile=/etc/jobcache-device.env
Environment=JOBCACHE_API_URL=https://api.jobcache.corbet.ch
Environment=JOBCACHE_DEVICE_ID=device_oracle_arm_%H
Environment=JOBCACHE_CPU_CLASS=oracle-arm-free
Environment=JOBCACHE_TASK_CLASSES=scrape
Environment=JOBCACHE_FALLBACK_ONLY=false
ReadOnly=true
Tmpfs=/tmp:rw,mode=1777,size=64m
DropCapability=ALL
NoNewPrivileges=true
MemoryMax=256M

[Service]
Restart=always
RestartSec=30

[Install]
WantedBy=multi-user.target
# /etc/jobcache-device.env (mode 0600, written from sops, never committed):
#   JOBCACHE_CONTROL_TOKEN=...   # sops --decrypt secrets/jobcache-control.yml | yq -r .token


# ============================================================================
# ARTIFACT 4 — Bare-binary fallback (NO container; smallest footprint)
# If podman isn't available, run the static-musl binary directly under systemd.
# Static-musl => no glibc needed; only `curl` + CA certs from the distro.
# ============================================================================
# On the box: apt-get install -y curl ca-certificates   (or dnf/apk equivalent)
# Copy the arm64 (or x86_64) static binary:
#   OUT=$(nix build --no-link --print-out-paths '.#packages.aarch64-linux.jobcache-device')
#   scp "$OUT/bin/jobcache-device" oracle-box:/usr/local/bin/jobcache-device
#
# /etc/systemd/system/jobcache-device.service:
[Unit]
Description=JobCache Tier-1 curl scrape device (bare static binary)
After=network-online.target
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/jobcache-device
EnvironmentFile=/etc/jobcache-device.env
Environment=JOBCACHE_API_URL=https://api.jobcache.corbet.ch
Environment=JOBCACHE_CPU_CLASS=oracle-arm-free
Environment=JOBCACHE_TASK_CLASSES=scrape
DynamicUser=true
Restart=always
RestartSec=30
ProtectSystem=strict
PrivateTmp=true
[Install]
WantedBy=multi-user.target
Source: wiki/content/architecture/TIER2-HEADLESS-PLAN.md