Core

Cache & Public-Repo Guard

Cache & Public-Repo Guard

Octopool owns a shared edge + D1 read-through cache for gh reads, and guards every repo route with a public-visibility check. Both keep private data out of the shared cache and reduce load on pooled identities.

Source: src/cache.ts, src/cache-policy.ts, src/cache-coalesce.ts, src/cache-swr.ts, src/edge-cache.ts, src/public-repos.ts, src/pr-state.ts, src/run-list-superset.ts, src/run-view-superset.ts, src/run-jobs-superset.ts, src/terminal-log-cache.ts, src/maintenance.ts, migrations 0002/0003/0006/0011/0013/0020/0021/0022/0023/0024.

#Configuration lookups

Caller authentication and membership results, pool policies, and identity lists share a bounded, 256-entry isolate cache of settled values. Entries expire 30 seconds after their load starts; a slow load never extends that deadline. Revoked caller tokens, retired identities, and pool policy edits may therefore take up to 30 seconds to reach a warm isolate. Authoritative identity rechecks use fresh D1 reads and bypass both settled and pending lookups. String-rewrite policy uses a separate deployment-wide PolicyCoordinator Durable Object, outside this cache; its caller authentication still uses the configuration cache. The object loads and validates the D1 primary on cold start and keeps the compiled snapshot in memory for at most 60 seconds, measured from the start of the load. Admin API writes run the existing D1 revision CAS through that same object and install the new snapshot before returning success, making them immediately visible to subsequent reads. Every caller/admin GET and relay policy load consults the object. Once the snapshot expires, concurrent reads share one gated primary reload before receiving policy. Writes that bypass the coordinator, including older Workers, manual D1 edits, and restores, become visible within 60 seconds. There is no isolate, edge, replica, or expired-policy fallback. Failed or uncertain writes discard the snapshot; failed reloads return 503 unavailable without serving expired rules. Eviction also requires a fresh primary load. See policy coordinator upgrades for deployment and recovery behavior.

For the isolate configuration cache, identical concurrent loads coalesce only within one Worker request, using an asynchronous context created at the fetch boundary. Different requests load cold entries independently and reuse successful values once available. No request waits on another request's pending promise: that request may finish or be canceled before its load settles. Failed loads are not cached, and clearing or invalidating a value prevents older pending loads from repopulating it. This keeps the hot-cache D1 savings without coupling request lifetimes.

#Read-through edge + D1 cache

Arbitrary repository GraphQL reads reuse this publication/coalescing machinery, with a separate key containing the canonically printed document, recursively sorted variable objects (list order is preserved), operation name, repository, pool, and App identity. They have a 60-second TTL and no stale fallback; HTTP-200 GraphQL errors do not cache. The CLI sends max-age=60 by default to match this TTL, including explicit --hostname github.com reads. Explicit Cache-Control headers set the caller's bound; max-age=0 or OCTOPOOL_FRESH=1 forces live reads. A positive requested age is bounded by the 60-second server TTL. The full query and variables keep different PRs, batches, and cursor pages separate. Same-repository aliases share one verified public repository/App-token boundary; cross-repository batches are refused. The CLI can remove an identity-proven top-level viewer { login } and restore it after the response, so viewer identity never enters the shared cache. The public-proof guard and current App eligibility still gate every hit. Lossless JSON text preserves native response bytes; the local viewer splice changes only the data object, retaining document key order.

On a cacheable route the relay computes a stable cache key, checks Cloudflare's data-center-local Cache API, falls back to github_cache_entries in D1, and serves a fresh hit without touching GitHub. D1 hits warm the edge cache. On a miss it first tries a token-free public web/raw endpoint when one can produce the same shape. A successful direct repository-resource response also proves that the repository is public, avoiding a separate repository metadata request; routes that need a pooled identity still run the explicit public-repository guard first. Successful results write through to both layers.

Fresh identity-specific entries are checked before conditional revalidation and token-free resource fetches. Reuse still verifies the identity's current pool/scope eligibility, public visibility, and the caller's maximum age; a missing credential or quota cooldown does not invalidate an otherwise eligible cached body. This prevents a warm pooled response from needlessly hitting GitHub again just because the shared anonymous key is empty.

Each identity-candidate pass probes the edge first, then batches the remaining D1 reads. Preference order is unchanged: an earlier D1 hit wins over a later edge hit. Validators use one D1 batch with anonymous-first order and the same stale/epoch predicates. Duplicate keys share reads within a pass; later passes and post-acquisition rechecks reread storage. Identity eligibility and public visibility remain separate authoritative checks. Forced-fresh requests skip mutable body reads but still batch validators; proven immutable entries can satisfy live reads as described below.

#Public PR landing snapshots

The fixed pr-ci-summary-v1, pr-ci-rollup-v1, pr-merge-snapshot-v1, pr-comments-v1, and pr-commits-v1 projections use pooled GraphQL reads with the normal public-repository guard gating response use and publication. The shape and detail cursor vary the existing cache key; a raw REST PR response, a different projection, and another cursor can never satisfy the request. The merge snapshot includes headRefName so a landing can bind the observed source branch to its acquired source facts. Its cache representation includes the exact canonical GraphQL document in the existing hashed key. Older projections lacking that field cannot be reused from edge, D1, identity caches, or concurrent fills. Future changes to that document retire its prior representation automatically; unchanged CI and raw REST keys stay warm. No schema, publication-epoch change, or cache purge is required.

Snapshots stay fresh for at most 60 seconds, including completed CI and merged PRs. They have no stale fallback: a previous merge snapshot cannot authorize a later landing. The CLI's recognized landing queries request a live read by default; callers must explicitly opt into a positive maximum age to reuse a snapshot for advisory work.

cache-control: max-age=0 performs one fresh pooled GraphQL query for that projection and writes its result through the normal cache. It does not disable caching for subsequent requests or bypass the pool. Repository visibility verification remains an independent requirement and can issue a separate guarded metadata read. GraphQL POSTs never use REST/page substitutes or conditional response validators. HTTP-200 GraphQL errors and null repository/PR results are returned without caching; quota headers update the identity's GraphQL budget, independently of REST's core budget.

Expired API-origin entries with an etag or last-modified validator are conditionally revalidated through the API before the normal API/pool fill chain; available no-quota page transports run first, with conditional API revalidation retained when the page fails. Anonymous REST entries are distinguished from web/raw/page entries by their stored x-ratelimit-resource header; identity-backed entries are always API-origin. A 304 reruns cache-hit integrity, then republishes the stored body with TTLs recomputed from that body. Web-origin validators are never sent across transports. Successful GitHub REST 304 validations consume primary rate-limit quota, for both anonymous and authenticated requests; they save body bytes, not quota.

If token-free API revalidation reports a rate limit (429, exhausted 403, or a valid Retry-After classified by the existing fallback policy), that request skips a second anonymous API attempt in its normal web fallback. Public HTML alternatives remain available, and pooled reads still require the live public-repository guard and identity selection. The observation lasts only for the current relay request; later requests try the anonymous API normally. Persisted anonymous rate snapshots remain advisory. Ordinary permission refusals, transport/server failures without rate-limit evidence, and policy denials keep their existing handling.

Anonymous API rate snapshots are written at most once per resource per 15 seconds in each Worker isolate, except when remaining quota first reaches zero or the reset window changes. Repeated exhausted responses share that interval too. Requests with an execution context schedule the write with waitUntil; other callers await only eligible writes. Failed writes keep the interval and can retry on the next eligible observation. Current-response rate-limit classification still uses the response headers immediately. Health, stats, dashboard and identity selection do not read github_public_api_rates; it is an advisory operator snapshot, not an authoritative shared anonymous budget.

#Cache key

SHA-256 (base64url) over a stable, sorted JSON of: pool, method, path, normalized query, the vary headers, the normalized route key, any validated state discriminator, and the server-owned publication protocol epoch (publication-v1). Every body key, including raw REST, identity, canonical, stale, and conditional-revalidation candidates, changes with this epoch. Readers never fall back to a previous epoch. Default pagination (page=1, per_page=30), default JSON accept variants, and an explicit x-github-api-version: 2022-11-28 (the transport default) are folded together; custom media types, non-default API versions and non-default query values still produce distinct entries. The key is pool-scoped, so pools never share cache entries.

For raw REST reads, omitted filter and explicit filter=latest also share an entry for commit and suite check-run lists and the base workflow-run jobs endpoint. GitHub defines latest as the default on these routes. filter=all, repeated filter parameters, attempt-qualified jobs, and filters on unrelated routes retain separate keys. This normalization changes no upstream query, freshness bound, or cache lifetime.

Shaped Actions run lists with non-default JSON media carry query_semantics: actions-run-list-filter-v1. Older pooled revalidation could publish an unfiltered canonical body under a filtered request key; this marker retires those entries and validators across shared, identity, edge, stale and coalesced reads. Default-JSON shaped lists and unshaped REST keys remain warm. New revalidation always keys the normalized request it actually fetches and publishes.

Non-default JSON Accept variants also carry body_codec: lossless-v1, using the same normalization as the vary headers. Raw blob/contents/README, octet-stream, diff/patch, and other custom media cannot reuse old opaque bodies or validators in edge, D1, identity caches, stale fallback, or fill coalescing. This field composes with the representation generations below. Default JSON keys stay warm: this is bounded media retirement, not a purge of hypothetical malformed non-JSON responses previously returned to default-JSON requests. Future opaque decoding is lossless regardless of the request's negotiation.

Default-JSON contents reads with a nonempty scalar ref carry contents-rest-v2. This retires raw-origin file objects that cannot represent symlinks and submodules correctly, including earlier contents-self-links-v1 bodies, across shared and identity keys, edge/D1 hits, validators, stale fallback, and late old fills. All new JSON contents fills use exact REST responses. The predicate covers plain filenames; it does not guess which paths are symlinks. Contents without a scalar ref, custom media (including their body_codec), blobs, README routes, and unrelated representations retain their keys. No publication epoch, public-repository proof, or R2 generation changes or cache purge are required.

Default JSON git_ref and git_matching_refs keys include git-refs-framing-v1. This retires older potentially incomplete ref objects and arrays for exact and matching heads/tags across shared and identity entries, edge/D1 hits, stale fallback, validators, and fill coalescing. Complete API-origin entries may also miss once; stored provenance is not guessed. Branch lists/views, Git objects, custom media, contents, Actions, R2, and public-repository proof keys retain their existing generations. No purge or schema change is needed.

The contents and Git-ref representation predicates follow their JSON eligibility, including missing, empty, and whitespace-only Accept. Explicit blank values keep their existing distinct vary-header keys and body_codec: lossless-v1; they are not folded into absent headers or into each other. This also retires old blank-Accept reconstructed contents responses. Raw/custom non-JSON media and contents without a nonempty scalar ref retain their keys.

Actions summaries also include a server-controlled representation generation (actions-summary-metadata-v3) in this common key. Run views, attempt-qualified views, and repository/workflow run lists, including canonical supersets and identity-specific entries, cannot reuse summaries from actions-summary-owned-v2 or earlier generations. Existing actions-summary-v1 clients keep the same wire format but miss those old entries in edge, D1, stale fallback, fill coalescing, and conditional revalidation. Raw REST and unrelated shapes have no Actions representation discriminator; the common publication epoch still applies. No cache purge is needed.

The generation also isolates late old writers and their validators; new readers never join old summary fills or revive old bodies through 304 revalidation. Actions jobs, raw REST, public-repository proofs, and terminal-log R2 keys retain their existing representations.

Release views and latest-release reads with release-summary-v1 similarly include release-summary-raw-v2. Existing clients cannot reuse the old HTML-derived bodies from edge, D1, stale fallback, fill coalescing, or conditional revalidation. This discriminator applies to release-summary-v1; raw REST entries still carry the common publication epoch. Existing clients retain this generation even for metadata-only projections, since their shape can also request the full response body.

Release views selecting only tagName, url, isDraft, isPrerelease, and publishedAt use the separate release-metadata-v1 shape. Its public-page response contains only those proven fields; the shape header keeps its cache entries separate from exact release summaries and raw REST in every cache path. HTML validators are omitted from the derived JSON, so an API 304 cannot revive page-derived metadata. Existing release TTLs, age bounds, visibility checks, and cache publication still apply. Forced-fresh reads fetch the page again. Mixed selections containing name, body, or createdAt keep the exact API shape; metadata never supplies those fields or a list response.

Issue timelines and the three issue-event list/view routes include issue-events-public-v2 for every representation, including raw REST and identity-specific keys. Older pooled bodies may retain private cross-reference details, even after anonymous revalidation removed their identity attribution. All old keys are retired across edge, D1, stale fallback, fill coalescing, and revalidation. Only anonymous responses populate the new generation. Caller conditionals bypass storage and use the anonymous API; a 304 never revives an old server-cached body. Repository/network activity feeds and unrelated routes do not carry the issue-event discriminator; they still carry the common publication epoch.

PR summary versions vary by x-octopool-public-shape: pr-summary-v1, pr-summary-v2, and unshaped REST requests cannot reuse each other's entries. V2 adds bounded merge, draft, and actor projections; a requested value omitted by the page causes one exact relay retry without the shape header. The retry preserves the caller's freshness bound, and neither summary version changes TTLs or the CLI's live-read field rules. Older CLIs retain v1 support; no cache purge is needed.

PR file-list routes may include a validated route_hint.pr_head_sha or closed/merged route_hint.pr_state discriminator. Clients that already know the current PR state can use that to avoid mixing entries across head SHAs while letting Octopool keep files warm longer. Hints are first checked against GitHub and then cached briefly in github_pr_state_proofs, so repeated cache hits do not need to re-contact GitHub just to validate the hint.

PR-state metadata uses the same MAX_RESPONSE_BYTES cap as response bodies: exactly the cap is accepted, and the first chunk crossing it cancels the read before JSON parsing or proof storage. Failed metadata reads, parsing, or matching do not insert or refresh a proof; the request continues with the ordinary unhinted files cache key. Proofs last 300 seconds. If a fresh proof's keyed body is missing, live verification is required before filling it. A failed live check ignores that discriminator for the current request, including stale-cache selection, while leaving the stored proof intact. A failed proof storage attempt also returns no newly trusted hint; an unknown write acknowledgment does not establish whether the write committed. These metadata failures preserve the unhinted route fallback; an oversized actual files response still uses the existing 424 fallback_local / github_response_too_large handoff.

The gh pr view --json files shim resolves the head with max-age=0, sends a dedicated pr-files-v1 shape plus the verified head on every bounded page, and resolves the head again after all hydration. If the head moved, the entire command falls back to real gh instead of returning pages that may span revisions. Missing heads and lists beyond the bounded pagination window also fall back.

#What is cached

Only successful 2xx responses on cacheable routes are stored, except pending 202 responses on repository statistics routes (contributors, commit_activity, code_frequency, participation, and punch_card). These responses reach the caller unchanged and store no body, so the next poll can reach GitHub's completed result. Concurrent followers reacquire fill ownership before fetching; there is no retry timer. A successful anonymous pending response may still warm the separate public-repository proof.

Readers also reject existing statistics 202 entries from edge, D1, identity-specific keys, coalesced completions, revalidation and outage stale fallback, including late old writes. No generation change or purge is needed. A forced refresh that returns pending leaves any ready body's original timestamps and contents intact. Statistics 200/204, their validators and bounded stale windows, and unrelated routes' 202 behavior remain unchanged.

The edge + D1 cache is bypassed when:

  • the route is a large-payload route or rate_limit (completed Actions logs use the dedicated R2 cache described below), or
  • the request carries a conditional header (if-none-match / if-modified-since).

Bodies whose serialized JSON exceeds 256 KiB (paged run lists, check-run sweeps) are stored only in the data-center-local edge cache, not in D1: megabyte-class row writes were the dominant cause of D1 DB is overloaded queueing under paged bursts. Oversized entries lose cross-colo sharing and D1 stale fallback but keep the hot same-client repoll pattern warm. A same-colo follower serves the confirmed edge publication directly; a follower in a cold colo reacquires coordinator ownership and performs one serialized takeover fill. This can produce one fill per cold colo, but never overlapping ownerless upstream requests.

Cacheable requests can bound cache age with a cache-control: max-age=N header. The bound applies to both fresh hits and outage stale fallback: an older entry cannot be served without successful upstream revalidation. For mutable resources, max-age=0 requires an upstream fetch or conditional revalidation, including when a cached timestamp equals the current time. Old entries remain eligible for conditional requests; a successful 304 confirms and republishes the stored body. Successful refills write through to the shared cache. Positive bounds let concurrent readers share an acceptable refill; zero-bound readers require upstream validation unless the cached entry is proven immutable. The CLI's gh pr checks resolves the PR head SHA with max-age=60. Zero-age reads skip body-cache lookups that cannot satisfy the age bound, while retaining validator discovery and serialized fill ownership, including waiting for an existing fill.

Live reads (including the CLI's OCTOPOOL_FRESH=1) may reuse an entry within its existing fresh TTL for file contents with a scalar full 40-hex ref, and Git tree/blob objects with a full 40-hex SHA.

Commit and comparison JSON remain live even with full SHAs: comment counts, user metadata, and signature verification can change without changing the Git object. Completed Actions runs, attempts, jobs, and logs retain live checks because they can be deleted. PRs/issues, checks/statuses/check suites (even at a SHA), refs/branches/rules, search and GraphQL also remain mutable. No new route becomes cacheable. Conditional headers retain their bypass. Positive caller age bounds, fresh expiry, identity eligibility, public-repository proof using the original entry creation time, and string-rewrite protection remain enforced. Live hits retain their original cache_created_at; they never manufacture newer run-watch completion evidence. Audit records use cache_status = hit, fallback_reason = immutable_live, requested_max_age = 0, and a null miss reason; no schema migration is needed.

#Token-free GitHub reads

Whenever a validated parser exists, Octopool prefers public page/raw and Git smart HTTP endpoints before the anonymous GitHub API. A parser miss falls back to the anonymous API in the same request cycle, then to a pooled identity where the route permits one. Successful direct repository-resource responses are themselves a public visibility proof; ambiguous search responses still require an explicit repository guard. Token-free-only shaped repo search uses the public repository page marker for that proof, avoiding both pooled identities and the configured verification token. The canonical route-by-route inventory is Token-Free GitHub Endpoints.

The main transport classes are:

  • PR diff/patch media requests (gh pr diff, or GET /pulls/{number} with a diff or patch Accept header) via github.com/{owner}/{repo}/pull/{number}.diff|patch
  • commit diff/patch media requests via github.com/{owner}/{repo}/commit/{sha}.diff|patch
  • compare diff/patch media requests via github.com/{owner}/{repo}/compare/{base...head}.diff|patch
  • human gh run list/view summaries (up to 25 results, with branch/status or workflow filters) and human/watch job/step metadata prefer public GitHub pages; machine run JSON and raw API requests use exact REST semantics, and log bodies remain authenticated
  • exact public GitHub API reads without caller credentials for repo metadata, commits, compare JSON, contents, README, PRs, issues, checks/statuses, Actions run/workflow metadata, branches, tags, labels, milestones, topics, community profiles, forks, stargazers, subscribers, deployments, Git object reads, languages, contributors, licenses, release assets, GitHub metadata/license/gitignore APIs, org repository lists, org public events and members, user/gist reads, user follower/following/event/key lists, reactions, assignees, repo-wide issue/PR comments and events, commit pull/check-suite/ branch/status metadata, network events, repository stats, repository search, and repo-scoped issue/commit search
  • branch refs, matching branch prefixes, and annotated-tag refs can use Git smart HTTP advertisements with exact REST-compatible IDs and object metadata; ambiguous lightweight tags fall back to the API
  • supported top-level gh pr view summaries and gh workflow view metadata can use bounded public GitHub page data before the anonymous API
  • release list/latest/tag/id/asset reads via unauthenticated api.github.com requests so pooled credentials never expose draft releases; top-level gh release view also uses exact API data, preserving raw Markdown strings through fills, cache reads, and revalidation
  • issue timeline and issue-event list/view reads via the anonymous API, so cross-repository issue and commit references retain public visibility; outages can use only the new cache generation with the existing age, retention, and repository-proof checks

Release bodies are never reconstructed from rendered HTML or replaced with changelog text. Whitespace, line endings, and Markdown syntax remain exactly as returned by the API. This costs anonymous API quota on release cache misses, including metadata-only views. If the API is unavailable, eligible exact stale data or the existing guarded local-gh fallback applies; pooled identities remain excluded.

Anonymous API rate snapshots are recorded by GitHub resource from API responses. When a public-page/raw/Git parser cannot satisfy a request, Octopool falls back to the anonymous API in the same request cycle.

Actions run pages must identify the requested repository and run in both the summary region and embedded job navigation, with a matching attempt when requested. The head SHA must come from a commit link inside that run's summary; document titles and links elsewhere cannot supply it. List cards end at their own closing element, so a following card or region cannot lend its SHA. Missing or conflicting ownership falls back to exact anonymous REST, then the existing pool path. In particular, title-only pages use REST's historical top-level head_sha, never the mutable pull_requests[].head.sha.

Run status comes only from the recognized status prefix before the first colon on the owned run link (or the owned status icon on a run page). Workflow names, display titles, and branch prose cannot supply status or conclusion. A list card's trigger belongs only to its workflow metadata region. The older sibling-timestamp forms remain supported: #N: pull request, #N: schedule/scheduled, #N: workflow dispatch, and #N: Commit <owned commit link> pushed; commit-link text cannot supply an event. Current cards put trigger prose in a separate span and repeat timestamps for responsive layouts. The copies must agree; manual dispatch and commit-push prose are parsed there, and ambiguous event prose is hydrated from the owning run page. Its disjoint Workflow run graph region supplies an exact on: <event> label beside the workflow link; the graph's graph_partial URL and workflow link must both belong to the requested run, and the event must be a documented GitHub event name. When the graph is absent, only confirmed summary prose push and pull request maps to push and pull_request. Ambiguous prose such as issue or issue comment is not canonical event evidence. Missing, conflicting, or unknown ownership falls back to REST. A list containing even one hydrated run whose event remains ambiguous falls back to REST as a whole, preserving exactness over page coverage. Manual summaries accept the owned Manually triggered timestamp only with a matching workflow_dispatch graph label. PR pages without an owned historical commit SHA still require REST, including queued or completed runs. Before any enrichment, a page with more than eight cards needing an event or full SHA falls back to REST, counting the whole page before request-limit truncation. At most eight run pages are hydrated concurrently, each capped at five seconds or the configured request timeout, whichever is shorter. The list page fetch keeps the normal configured transport timeout. After parsing and the card-count check, a separate shared 2500 ms hydration deadline covers all run pages, redirects, body reads and commit patches. List fetching and parsing do not consume that deadline. A failed hydration or deadline aborts siblings; no partial list reaches cache publication. Failed hydration adds at most roughly 2.5 seconds after the list page, plus synchronous parsing/scheduling overhead. These bounds also apply to canonical 25-card fills; cache TTLs and machine JSON paths are unchanged. This deliberately bounded markup contract can cost more API reads when GitHub changes its layout. Valid known cards retain the existing wire fields, filters, and state-based TTLs; fresh job/attempt metadata still independently governs terminal caching.

These ownership regions use parse5's HTML DOM and source locations, not markup stripping or regular-expression boundaries. Scripts, comments, styles, templates, raw-text elements, and foreign content cannot supply commit links. Embedded job navigation is read only as JSON from its unique script element. HTML element and attribute names follow browser case rules, and entities are decoded only by the HTML parser. Parse errors, inferred or overlapping ownership boundaries, unusual element names, and duplicate identity evidence fall back to REST. Only bare fragments with an implicit document scaffold may omit the doctype; full documents must declare it. This conservative policy can increase API fallback for malformed or changed GitHub markup.

An abbreviated summary commit link can still expand without API quota, but only from a single well-formed patch with a full 40-character SHA matching that abbreviation. Merge patch series, mismatched headers, and ambiguous or incomplete patches fall back to REST. This deliberately uses more API reads for pages that cannot prove ownership.

Successful web reads are cached in the same D1 table with no source identity. A cached web hit still re-checks that public proof covers the entry before returning it.

#TTLs

Per route kind and response state (cacheTTLSeconds):

  • base workflow runs → 60s while active; completed runs → 10% of age since updated_at, clamped to 60s..10m; base job lists (including filter=latest) → 60s even when terminal, because reruns reuse the run ID; completed attempt-qualified run views, job lists with a proven completed attempt and every returned job completed, and completed single jobs get 6h (21,600s) fresh plus up to 24h bounded stale fallback
  • checks, check suites, and commit statuses → 60s fresh while active; settled collections → 10% of age since the newest item completed_at/updated_at, clamped to 60s..5m; ref-named commit routes retain their 120s cap, and all retain up to 5m bounded stale fallback
  • run/workflow lists → 60s while active, 2m when every returned run is completed; lists remain mutable because new runs can appear
  • PR files with a validated state discriminator → 5m; PR commits, reviews, comments, issue comments/events/timeline, and undiscriminated PR files → 1m..5m
  • supported repository-scoped gh search issues|prs shim calls use anonymous API before any allowed search-bucket identity
  • merged PRs and closed issues → 1h; closed-unmerged PRs → 10% of age since updated_at, clamped to 2m..1h; open PRs → the same heuristic clamped to 2m..5m; open issues → 5m
  • release lists/latest → 5m; release by tag/id → 1h
  • immutable commit objects → 24h; commit lists → 5m; contents → 1h
  • repo metadata → 10m; workflow metadata → 1h
  • active/unknown-run logs, rate_limit, and conditional requests still bypass

Age is measured at cache write time and the heuristic rounds down to whole seconds. Missing, unparseable, non-string, or future updated_at values retain the minimum TTL (60s for plain completed runs, 120s for unmerged PRs). Merged PRs keep their fixed one-hour TTL; completed attempt-pinned runs keep their fixed six-hour TTL. Ref-named route caps and stale fallback windows are unchanged: plain runs retain 5m of bounded outage fallback after fresh expiry, and PRs retain 1h.

Terminal attempts and single jobs are immutable because reruns create new attempts and job IDs. Existing entries acquire the six-hour fresh TTL when refilled; cache keys and stale windows are unchanged.

The caps bound fresh-cache reuse after a re-run to at most 10m and after a PR reopens to its computed TTL (at most 1h). Outage stale fallback remains separately bounded as above. Explicit caller maximum ages still constrain both fresh and stale reuse; max-age=0 requires upstream validation except for the proven immutable classes above. CLI live-state PR fields and unproven watch completion confirmations request zero-age reads. Run watches can reuse terminal snapshots fetched after watch start and jobs proven newer than both watch start and run completion; see the watch contract.

Settled check-run/check-suite collections require every returned item to have status completed; status lists require terminal success, failure, or error states, and combined status also requires a terminal aggregate state. The newest supplied completion/update timestamp across every item determines age. Empty collections, missing item timestamps, invalid/non-string timestamps, and future timestamps retain 60s. Reruns and late workflows can still add items to a completed collection, so fresh reuse never exceeds 5m (120s for ref-named commit routes). Existing entries acquire the new TTL only when refilled; live reads and stale windows are unchanged.

REST issue state closed and page-derived CLOSED both receive the one-hour TTL; classification preserves cached bodies and raw response states.

Commit CI aggregate keys carry a server-owned policy generation, so existing hour-long entries cannot survive the shorter TTL rollout through edge, D1, revalidation, or stale fallback. Completed attempt-qualified runs/jobs and individual job IDs retain their longer retention. Ordinary reads remain bounded cache reads; use OCTOPOOL_FRESH=1 for live evidence.

#Stale-while-revalidate

A supported cache-accepting read without an explicit cache-control: max-age may reuse an entry expired by less than 60 seconds and schedule a refresh with ctx.waitUntil. This applies only to run_view, run_jobs, run_list, workflow_run_list, commit_check_runs, commit_check_suites, commit_status, commit_statuses (including each _ref variant), job_view, pr_files, issue_comments, issue_comment_list, pr_view, and contents. It includes matching canonical and exact filtered run-list entries. Fresh entries are preferred. The retained D1 entry must pass the same publication-epoch, representation, active-identity, public-repository proof, and response integrity checks as a fresh hit; SWR does not revive expired retention or extend timestamps. Edge-only bodies that are no longer retained fall through to a normal miss.

Such a read may observe state up to its fresh TTL + 60 seconds old during normal operation: an active run with a 60-second TTL can be almost 120 seconds old. Every explicit maximum age, including positive values and zero, disables SWR. Watch commands and live PR fields retain their live-read behavior. Beyond the SWR window, normal miss/revalidation applies; the separately bounded outage fallback described below still applies on failures.

Refreshes reuse the normal token-free/identity selection, quota and cooldown feedback, string-rewrite protection, sanitization, size caps, and owned publication path. One renewable publication lease per canonical cache key coalesces refreshes across isolates, including transitions between shared and identity-specific body keys. Body fills also use nonblocking ownership acquisition: an existing foreground fill wins. Each isolate runs at most eight refreshes, with a 20-second deadline; excess work is skipped, and non-abortable storage continues to count until it settles. Refreshes never recursively schedule SWR.

Because refreshes spend pooled quota, they try admission under the same authenticated caller/client key, with the configured concurrency limit reduced by one to reserve a slot for foreground work. They never queue for admission or turn the served stale response into an overload error. A configured limit of one disables refreshes. Busy ownership or admission, timeouts, and upstream failures leave the served response unaffected; unsuccessful fetches leave the old entry intact. Later reads can retry while still within the SWR window.

SWR uses the existing cache_status = stale, with fallback_reason = stale_while_revalidate in the single client audit row and relay.stale_reason in the response. It retains requested_max_age = NULL and cache_miss_reason = NULL. Background work emits no second client audit row; failures are logged and identity quota feedback is still recorded. The CLI's existing “served from shared cache (stale)” notice remains accurate, including when refresh is skipped, so no CLI change is required. Cross-form raw jobs reuse instead records fallback_reason = run_jobs_superset, retaining the same stale status and response reason.

#Cache miss audit reasons

audit_events.cache_miss_reason explains why a cache-accepting read did not reuse a response. It is NULL for hits (including revalidated/coalesced hits), served stale responses, live max-age=0 reads, non-cache routes, and failures before cache lookup. Historical rows also remain NULL. Conditional requests on otherwise cacheable routes are uncacheable.

ValueMeaning
absentNo entry exists for the consulted cache key.
expiredAn entry passed its fresh TTL and the request ultimately did not serve it as stale. R2 includes expired retention or the default one-hour revalidation window.
caller_max_ageAn entry is fresh by its TTL but older than the caller's positive maximum age.
unusableA present entry is rejected for another reason, such as publication epoch, identity ownership, body decoding, or incomplete collection projection.
uncacheableThe request or response cannot be cached (including logs without proven completion).

Reasons come from the existing edge/D1/R2 lookups, without diagnostic D1 queries. Across consulted identity/canonical candidates, a present rejected entry takes precedence over an absent key; priority is unusable, caller_max_age, expired, then absent. A non-cacheable response takes precedence over lookup reasons. Keys for different epochs or representations are not scanned: if only a different key exists, the consulted key is absent. Publication ownership is enforced on writes; reads report only rejection evidence they actually observe. Storage failures without a classified lookup may remain NULL.

#Completed Actions log cache

Non-conditional job_logs requests first read the exact pool/path key in ACTIONS_LOGS. A valid, unexpired object with the current codec was written only after completion proof, so it proves the same job terminal without a D1 job-proof query, HTML fetch, or anonymous metadata call. The public-repository guard and log existence revalidation still apply. Absent, expired, or legacy-codec objects fetch the log immediately; conditional requests retain their cache bypass.

On an R2 miss, a successful, body-cap-compliant 200 log download returns without waiting for completion proof or R2 publication. The proof below runs through ctx.waitUntil in a separate cancellation scope with a 20-second deadline, leaving time to audit within the 30-second background lifetime; only proven logs publish the exact bytes served. Deferred proof never consumes a caller/client backend permit. Each isolate allows four in-flight proof/publication tasks; excess misses skip proof, publish nothing, and still write their audit row with cacheable: false and proof outcome deferred_skipped. Slots remain occupied until underlying work settles, including non-abortable storage after timeout. Failed, oversized, and conditional downloads do not start deferred proof, and background proof or write failures are logged without affecting the response.

The deferred job_logs proof checks D1 for a successful JSON job_view response in the same pool, at the exact job metadata path, with that job ID and status: "completed". Completion is permanent for a job ID: a re-run creates new IDs. This proof can therefore use rows past both their freshness and stale-retention expiry, until normal cleanup removes them. It does not extend metadata response freshness or accept client-supplied completion claims. The current cache publication epoch still applies. Migration 0022_terminal_job_proof.sql adds an exact pool/path index covering anonymous and identity-specific job entries, including API-version variants. The lookup is one indexed query, without a repository-wide scan. Cached run_jobs bodies are not searched because there is no index by contained job ID.

If D1 has no completion proof or is unavailable, the relay tries https://github.com/{owner}/{repo}/runs/{job_id} without credentials or API quota. The page must have one unambiguous selected job section containing an exact repository/job header URL, a terminal conclusion with a valid completion timestamp, and check-steps[data-job-status="completed"]. Any navigation or check-run identity markers must agree. A redirect is accepted only when the final URL is the same short URL or the same repository's canonical /actions/runs/{run_id}/job/{job_id} page. The existing egress policy, response cap, and one timeout covering headers, redirects, and body apply. Wrong jobs, active/queued or ambiguous markup, unexpected destinations, oversized pages, and timeouts do not prove completion. This covers collectors that list run_jobs and read logs without ever fetching job_view.

Annotation contents are not completion evidence: the parser validates their container boundary but ignores the contents, including tables whose omitted tbody GitHub leaves for the HTML parser to insert. Header, timestamp, job identity, and steps status still come from the selected job outside annotations. Skipped-job blank slates without a completion header and steps status do not prove completion through this page path.

If the HTML proof also fails, the existing callGitHubWeb fallback fetches the exact job endpoint from the anonymous REST API. Ordinary job_view relay responses remain REST; the page provides only internal completion evidence. The final fallback consumes anonymous API quota and can fail when that quota is exhausted. No pooled-identity metadata call is added. If no source proves completion, the fetched log remains uncached. A cached completed run cannot prove a job terminal. Structured Worker logs emit octopool.actions_log.completion_proof with pool, path, outcome (r2_cached, cached_job_view, web_page, anonymous_api, deferred_skipped, unproven, or error), and a deferred boolean, without changing the HTTP response.

Whole-run log archives remain native GitHub CLI fallback; only job-log routes use this cache. Cached completion does not renew public visibility: the existing public-repository guard still applies, and only a successful 2xx anonymous API metadata response records a new public-repository proof. A proven-terminal log uses the dedicated ACTIONS_LOGS R2 bucket, keyed by pool and exact job route path, so immutable log downloads are shared without putting their large payloads in D1. Jobs from separate run attempts retain their distinct job IDs.

R2 stores the raw log bytes, content type, original body encoding, and a retention timestamp. Corrected writers also set the exact body-codec: lossless-v1 object metadata marker. Objects with a missing or different marker are misses before serving or existence-only renewal. The Worker downloads and serves the original bytes, then replaces the object after deferred completion proof; a failed download, proof, or write leaves the previous object intact. Legacy base64 objects were already reversible but also miss once under this format contract. A late old writer produces another marker miss. The bucket prefix and seven-day lifecycle are unchanged; no purge or bucket migration is required.

An R2 object younger than one hour supplies its own terminal-status proof and can be served without contacting the log endpoint. Older objects also make an authenticated log request without following its redirect: a validated 302 Location confirms existence and refreshes the retention timestamp, while 404 purges the object and returns GitHub's deletion response. Thus a deletion can remain cached for at most the bounded one-hour no-log-probe window, not the full retention period.

An explicit cache-control: max-age=N also bounds this no-log-probe window. A log older than that bound requires the same authenticated existence check before reuse; max-age=0 requires a fresh existence probe even for a newly cached log. Positive bounds never extend the one-hour default. A failed check retains the existing backend/fallback behavior instead of serving an unvalidated cached success.

Objects untouched and unconfirmed for seven days expire. Reads enforce that lifetime from object metadata: expired objects are treated as misses and removed, so lifecycle cleanup timing can never cause stale data to be served. R2 read, write, or probe failures never fail a relay request: Octopool uses the existing authenticated redirect-validation path instead.

API and log redirect bodies are released before following an allowed log URL or completing an existence probe. Rejected redirects, including invalid locations and chained downloads, also release their bodies. Cleanup failures preserve the original result; the redirect allowlist, credential stripping, quota headers, and log bytes are unchanged.

Operator provisioning is a one-time bucket plus lifecycle setup. The required lifecycle rule is: enabled for prefix github-actions-logs/v1/, delete objects seven days after creation. Apply it with Wrangler (or configure the identical rule in the R2 dashboard):

wrangler r2 bucket create octopool-actions-logs
wrangler r2 bucket lifecycle add octopool-actions-logs octopool-actions-logs-expire github-actions-logs/v1/ --expire-days 7

The operator owns this rule; worker code does not scan R2 or manage bucket lifecycle.

As with edge + D1 hits, Octopool runs the public-repository guard before returning an R2 log hit. Successful hits are audited as cacheable hit events and count as cache-served responses. R2 misses report miss immediately; their audit rows wait for deferred proof to retain proven-log cacheable: true and unproven-log cacheable: false accounting, with duration captured before background work. Neither outcome counts upstream requests, including visibility and log-existence probes. Requests carrying If-None-Match or If-Modified-Since skip the completion lookup and all R2 reads and writes, preserving the normal conditional-request bypass path.

#Workflow metadata reuse

A raw, queryless numeric workflow view can reuse its matching object from a fresh raw page=1&per_page=100 workflow-list cache entry. This avoids fetching the same workflow metadata again when a machine-readable run list hydrates workflow names before a run view. Reuse requires one unambiguous numeric ID and API URL match; the complete REST object is returned unchanged. Plain .yml/.yaml filenames can reuse the catalogue too, but only when it is complete (total_count matches its rows and there is no pagination link), exactly one row has that .github/workflows/ path, and that row is active with a valid numeric ID and matching repository API URL. Partial lists and ambiguous or inactive filename mappings retain the ordinary view fetch. A missing item never proves that the workflow is absent, and a cold catalogue is not fetched just to answer a view.

The lookup preserves the pool, API version, JSON media key, source expiry, requested maximum age, current identity eligibility, and public-repository guard. Anonymous entries are checked before loading pooled identities. It publishes no view alias and does not refresh the source. List validators, lengths, and pagination links are omitted from the derived view. Shaped/HTML metadata, custom media, query parameters, conditionals, and forced-live reads retain their existing paths. Optional lookup failures continue through the ordinary view fill; policy and visibility denials still propagate.

#Actions run-list superset

Machine run JSON does not request this shaped superset. It uses existing unshaped REST cache keys and validates the returned page against the effective CLI limit before any lazy workflow-name hydration. This increases API/cache cost in exchange for native field semantics; it does not change cache generations or the human/watch page adapters.

Repo- and workflow-level run-list requests carrying x-octopool-public-shape: actions-summary-v1 can share one canonical cache entry per pool and exact route path. Common requests for at most 25 runs use an unfiltered page=1&per_page=25 entry, which the validated public Actions page can fill without GitHub API quota. Larger repo-level requests retain the page=1&per_page=100 API superset; larger workflow-level requests stay exact. Every entry uses the existing state-aware run-list TTL policy. Fresh variants filter the cached runs by exact head_branch, or by a status value matching either the GitHub run status or terminal conclusion, then apply per_page and limit truncation locally.

The derived response's total_count is the number of matching runs found in the cached canonical page before truncation. Shim consumers ignore totals beyond the returned page; this is deliberately not a claim about older GitHub pages. If local filtering returns fewer than the requested limit, Octopool falls back to the exact upstream filtered request because a bounded public page cannot prove that older matching runs do not exist. Page values above 1, page sizes above 100, workflow-scoped page sizes above 25, unknown query parameters, unsupported GitHub status values, and requests without the shim shape keep exact upstream and per-query cache behavior. Conditional shim requests bypass the canonical cache but still translate the shim-only limit into a capped upstream per_page and shape successful responses locally. All other exact shaped requests, including workflow-scoped paths, use the same translation and never forward limit to GitHub. Locally shaped responses omit ETag, Last-Modified, Content-Length, and Link because those validators, lengths, and pagination links describe the upstream representation, not the transformed body. Public Actions pages with exact totals must expose at least min(total_count, per_page) parseable cards. Capped totals are lower bounds and require at least per_page cards (page requests are bounded to 25); otherwise Octopool discards the page and falls back to exact anonymous API JSON. Branch/status-filtered public pages are never treated as exact: GitHub can report only the visible matching card count while older API matches still exist. Underfilled canonical filters therefore go directly to the exact anonymous API/pool fallback chain.

Before fetching a missing or expired canonical page, the relay checks for a fresh exact filtered entry, then a fresh shaped 100-run entry with the same pool, route path, media, API version, and eligible source identity. A larger entry must contain at least 25 runs, or be a complete shorter API page whose total_count exactly matches its returned runs and which has no pagination Link header. This includes a confirmed empty list. Only its first 25 participate in the smaller page's filtering, derived total, and underfill check. Reuse keeps the source expiry and requested maximum age without publishing a 25-run alias or refreshing either entry. Unshaped REST entries remain separate. Exact entries preserve GitHub's total count and may legitimately contain fewer runs than the requested limit. A failed exact lookup leaves the canonical fill unchanged, including ordinary storage/configuration failures; explicit policy and visibility denials still propagate. Larger-page lookups follow the same optional-probe rules. Both probes check anonymous entries before loading pooled identities. Coalesced and revalidation recovery hits apply the same completeness check as ordinary fresh hits. During an outage, an underfilled canonical page cannot become an empty or partial success: the relay uses an eligible exact stale entry or retains the normal typed failure. Both cache paths keep the existing identity, public-visibility, retention and explicit maximum-age checks; max-age=0 still requires upstream validation.

#Exact run views from cached REST lists

After exact shared/identity hits and eligible SWR entries are exhausted, a plain GET /repos/:owner/:repo/actions/runs/:id can reuse one complete run object from a fresh cached repository or workflow run-list page. The source page must contain exactly one matching numeric ID and its run URL must identify the requested repo/run. The response preserves the entire sanitized object, including extra fields; it does not reconstruct missing values. List validators, lengths, and pagination headers are removed. The source timestamps and fresh TTL remain authoritative, including a second age/expiry check after public-proof verification. No run-view alias is published.

GitHub's workflow-run REST documentation and OpenAPI description give both list item types and the single view the same workflow-run schema. The documentation examples omit referenced_workflows and previous_attempt_url from lists, so those examples alone do not prove equality. A field-for-field comparison on 2026-09-29 UTC of completed openclaw/octopool run 36512885551 found all 35 fields and values equal between its single view, repository list, and workflow 321064621 list filtered by branch, status, event, and head SHA. This includes triggering_actor, display_title, path, run_started_at, previous_attempt_url, referenced_workflows, and all check_suite_* fields. Existing public-page summary fixtures are projections and cannot supply this exact REST shape. Tests cover the complete shape, sanitizer equivalence, preserved extra fields, and rejection of every missing field.

Reuse requires all 35 observed/schema fields, even optional ones. Incomplete lists fall through to the ordinary view fetch. Only unshaped default-JSON responses qualify; API-version and normalized media variants must match. Scalar actor, branch, event, status, created, check-suite, head-SHA, and pagination filters preserve run objects. exclude_pull_requests=true, repeated/unknown parameters, and summary projections cannot supply a view. An explicit exclude_pull_requests=false is allowed. View queries, attempt-pinned views, conditional requests, live max-age=0, and background refreshes retain their existing paths. Positive maximum ages constrain the source list's age. Pool, publication epoch, current source-identity eligibility, and public-repository proof checks are the same as for exact hits.

Migration 0024_run_list_items.sql adds github_run_list_items, indexed by pool, repository path, and run ID. Insert/update triggers maintain membership in the same guarded transaction as body publication: at most one insert per unique run in a page, with no membership writes for pages over 100 runs or shaped lists. Replacements remove the old page's memberships, and normal cache deletion cascades to its index rows. Rejected publications cannot change membership. Existing bodies are not scanned or backfilled; the index warms as pages are republished. Edge-only bodies are not indexed. One indexed query selects at most eight fresh matching pages, newest first; candidates that fail shape, query, identity, or proof checks never become hits. There is no full-table body scan, cold list fetch, or source lifetime extension. A successful reuse audits cache_status = hit, fallback_reason = run_list_superset, and a null miss reason.

Apply migration 0024 before or with the Worker deployment, before it serves traffic. No CLI upgrade, cache purge, or re-login is required.

#Actions attempt job-list superset

Shaped human gh run view and gh run watch reads resolve the run's current positive run_attempt, then request /actions/runs/{id}/attempts/{attempt}/jobs. That attempt-qualified path is immutable after all returned jobs complete, while the base run and base jobs endpoints remain short-lived because a rerun can change both after they previously appeared terminal. Before granting the six-hour job-list TTL, Octopool verifies that exact attempt is completed; a list of currently completed jobs alone is not treated as proof. A fresh cached run-view response can supply that proof when its run ID and attempt match and its source identity and public-repository guard remain eligible. Both the base run view and the exact attempt view qualify, including pooled responses when anonymous metadata is unavailable. The existing cache owner and expiry remain authoritative; no separate completion store is created. Anonymous proof is checked before loading pooled identities, so an identity lookup outage does not discard usable public metadata. Pooled proof still requires an eligible source identity. Explicit age bounds apply to this lookup, and live or conditional requests still require the direct attempt check. An active run, a different attempt, expired metadata or a revoked source cannot extend the jobs' lifetime. Without an eligible cached proof, Octopool keeps the existing public-page/anonymous-API check and conservative 60-second fallback.

Equivalent shaped page=1, omitted/latest filter, and per_page values up to 100 share one attempt-qualified cache entry, filled from at most three 100-job API pages. Octopool slices that complete superset locally and removes upstream representation validators, lengths, and pagination links. All pages must have consistent valid total_count metadata and the merged list must match that count. A remaining Link: rel="next" also prevents completion, even when the count matches; the first page's link is removed only after a complete merge.

Merged API job lists also discard the first page's ETag, Last-Modified, and Content-Length before storage. A page-one 304 cannot validate later pages of the cached aggregate. Revalidation recognizes older merged entries with more than 100 jobs and ignores their page validators, so an expired or forced refresh fetches every page again. Single-page validators, fresh cache hits, and bounded outage stale reads retain their existing behavior. No cache-key or TTL change is needed. This protects the complete stored aggregate; each shaped response still returns at most 100 jobs. Human run view and watch request later pages separately, but those pages reuse a fresh, complete aggregate for the same path, shape, and source identity when available. Different page sizes and pages beyond the end are sliced locally without additional GitHub calls. The original source timestamps, expiry, public-repository and identity checks, and caller age bounds still apply. Live and conditional requests bypass this reuse. A cold later-page request keeps its exact upstream request and cache key; it does not fetch a new aggregate or publish an alias.

If a partial rerun exposes count metadata that disagrees with the returned job set, a count disagreement alone does not establish another page or prove which successful jobs were reused. Octopool rejects that ambiguous shaped response with pagination_exhausted, without caching it, inventing jobs, or treating a short page as a complete summary. Supported gh run watch stops with an explicit error on that refusal and never starts real gh; ordinary run-view fallback remains available. filter=all, unshaped REST requests, unsupported query variants, and later pages without a usable aggregate retain exact upstream semantics.

Machine run-view jobs use up to 10 unshaped canonical returned-attempt pages of 100, with completeness/identity validation before output. Human run view and watch share that CLI collection bound; the Worker's 3×100 shaped superset remains separate. Requested attempts qualify machine output URLs while returned attempts own jobs acquisition; CLI documentation describes the native defaults, explicit safe-integer boundary, lazy names and whole-command fallback.

Raw default-JSON first-page jobs requests with a smaller page size can reuse a fresh raw per_page=100 entry for the same path and equivalent filter (omitted and latest are equivalent on the base jobs endpoint; filter=all stays exact-key only). Its total_count must equal the jobs array length, the entire array must fit the requested page, and it must have no Link header. This includes empty lists. Octopool returns the complete REST body unchanged, including extra fields, while omitting source representation validators and lengths. It keeps the source timestamps and expiry without publishing an alias or extending its TTL.

The optional lookup checks one larger page for the shared cache, then one per eligible identity; it never fetches a cold larger page. Requests for 100 jobs skip this probe. Pool, run, attempt, filter, API version, media type, current identity eligibility, public visibility, and positive maximum-age bounds still apply. Shaped responses, custom media, later pages, unsupported queries, conditionals, and forced-live requests retain their existing paths. Lookup failures fall through to the exact request; policy and visibility denials still propagate.

#Raw latest-attempt jobs equivalence

Raw default-JSON /actions/runs/{id}/jobs with omitted or filter=latest can reuse /actions/runs/{id}/attempts/{n}/jobs, and vice versa, for the same page and page size, including later pages. The latest attempt must first be proven by a fresh cached plain REST run view, or a complete run object from an indexed fresh REST run-list page. The proof must identify the exact repository/run and a positive safe-integer run_attempt. Attempt-qualified run views, stale proofs, public-page shapes, or job fields alone never prove which attempt is latest. A requested non-latest attempt keeps its exact path.

Every job in the reused page must carry the proven run_attempt and matching run_id; the array length must agree with total_count, page, and page size. Mixed-attempt pages and incomplete pagination are refused. An empty plain page must have a creation timestamp strictly later than the proof, since it has no job fields to establish its attempt; equal second-resolution timestamps are ambiguous and cannot supply that ordering. Source pagination links must name its exact GitHub API endpoint (named repository, or the numeric repository ID from the same cached run proof); they are rewritten to the target endpoint and filter while retaining page navigation. Source validators and lengths are removed; the entire JSON body, extra fields, identity, creation time, and expiry are preserved.

Lookups use exact keys for the run view and opposite jobs endpoint (at most two filter variants per eligible identity), or the existing indexed run-list lookup capped at eight pages. No bodies are scanned, no cold proof/job request is fetched, and no alias is published. All candidates retain normal pool, API version, representation, publication epoch, active-identity, and public-visibility checks. Proof and jobs freshness are checked again after these checks. Lookup failures retain the normal exact fill; policy and visibility denials propagate.

The jobs entry's own fresh TTL remains authoritative, including the six-hour completed-attempt rule. Source jobs expired by less than 60 seconds may serve through SWR only without an explicit maximum age, while the latest-attempt proof must still be fresh. The background refresh targets the source's normal cache key and publication owner. Positive maximum ages bound both proof and jobs; max-age=0, conditionals, unsupported queries, shaped/custom-media requests, and filter=all do not cross-serve. Hits and SWR reuse audit fallback_reason = run_jobs_superset with a null miss reason.

Cross-form reuse does not slice arbitrary smaller pages: the pre-existing raw larger-page helper only supports a complete collection that already fits the requested first page, on the same endpoint. No new migration, CLI upgrade, or cache purge is required.

#Cache-hit integrity

A fresh or bounded-stale hit is only served if:

  • the source identity recorded on the entry is still an active candidate for the route (web-origin entries have no identity), and
  • the repo's unexpired public-visibility proof still covers the entry (re-checked during GitHub outages / secondary-rate-limit — see below).

If the eligible token-free and pooled backends are unavailable, depleted, cooling down, or rate-limited, Octopool may serve an expired public cache entry for a short route-specific grace window. Mutable CI payloads get only minutes; terminal CI payloads get up to a day; PR/issue detail routes get up to an hour; immutable-ish commit views can get up to a day. Requests without an age bound retain this outage fallback; an explicit bound must also be satisfied. Otherwise the existing typed failure/local-fallback flow applies. Stale serves still run the public-repo guard and active-identity check before returning.

Transient GitHub server responses (500, 502–504, and 520–524) and recognized fetch/response-stream network or timeout failures can also use that existing stale window. This does not renew the cached body's timestamps, extend retention, or bypass an explicit age bound. HTTP response quota observations are still recorded for the selected pooled identity; transport failures do not invent quota or cooldown feedback. Without an eligible cached body, the original response or error is preserved. Terminal HTTP responses, response-size refusals, policy denials, and local response-body ownership errors retain their existing behavior. The same recovery applies when a later page of a shaped jobs collection is unavailable: only a complete eligible cached aggregate can replace the failed refresh. Otherwise the existing pagination_exhausted refusal remains; partial pages are never published.

Cache publication is awaited before returning a miss response. D1 grants renewable publication authority: (protocol epoch, resource key, global AUTOINCREMENT ID, random token). The capability comes only from a successfully committed RETURNING result. D1's execution clock sets/checks the eight-second lease; owners renew every three seconds, never shorten a later persisted deadline, and retain the final renewal before publication. Neither a DO waiter map nor a pre-write renewal grants permission to publish: the actual INSERT/UPDATE requires the exact live D1 owner and the original unexpired evidence deadline. Both absent-row insertion and replacement are guarded. IDs remain unique after completion/GC because sqlite_sequence is retained. No payload has an owner foreign key or deletion cascade.

Pool DOs notify/coalesce body waiters; one reserved global proof DO coordinates normalized repository names across pools and both verdicts. A nonblocking attempt precedes each of the three existing anonymous observations. Busy/unknown acquisition still fetches normally but never persists that observation opportunistically. The scope releases before explicit proof guards, body-key switches, finalization, or canonical/exact continuation. Body ownership may precede proof ownership; proof ownership never waits for a body owner. Anonymous 304 revalidates a body, never repository visibility.

Each coordinator serializes publication acquisition, renewal, completion, and waiter expiry per resource. D1 waits do not block unrelated resources or pool identity selection and feedback. Followers release the resource queue before waiting; owner lookup and waiter registration stay ordered with completion. D1 owns fencing and expiry across restarts and pools. Pool and global proof Durable Object names and quota state are unchanged.

Body fetch/304 validation time is captured with the initial response, before awaited proof publication, explicit guards or first-page aggregation. Internal response/time metadata keeps that evidence and its original expiry through SQL acknowledgment without adding fields to the relay payload or stored user body.

Storage (shared, edge_only, none, failed, rejected, unknown) and completion acknowledgment (accepted, lost, unknown) are distinct internal results. A zero-row write is rejected unless an exact immutable already-committed receipt recovers a replay/lost acknowledgment. Intentional statistics nonpublication uses none without a publication ID; it revokes the exact owner, including expired cleanup, without claiming that absent data is shared. A stored denial completes successfully before returning repo_not_public. Shared proof completions identify the actual published or reused proof, not the notifier's newer ownership ID. Ownership-only completion does not fabricate a publication receipt. Shared waiters reread authoritative D1; a waiting proof follower cannot use an older positive edge entry to supersede the completed denial. Missing results and expired owners return to acquisition before upstream work. Failure to obtain D1 authority cannot create a local/shared owner. Every lost/unknown completion rereads authoritative proof before authorizing from a probe, including when storage history is unknown. Only a receipt from the same or a newer owner can supersede that observation; an older positive proof cannot override a newly observed denial. That request-local evidence floor survives retries, other owners' reuse notifications, and historical-proof fallback without adding persistent state or a hot-hit lookup. Revocation can clean an expired exact owner, but that cleanup is not an accepted live completion. Persistence failure alone can still permit the direct request when its exact owner completes live and the observed evidence has not expired; it does not mint a shared proof. Receipts and capability tokens stay internal, outside relay envelopes and health snapshots.

D1 acceptance precedes the edge put for D1-sized bodies and proofs. Oversized bodies still store only at the edge, but require a small guarded D1 authorization statement. Each edge entry embeds an immutable absolute expiry and protocol epoch; D1 warming preserves that expiry. Edge storage has bounded freshness, not CAS or linearizable latest-value semantics: a delayed accepted put can replace a newer edge entry until its original expiry. It does not extend that expiry, and failed/stale publication never deletes a replacement edge entry. Independent hot body-plus-covering-proof hits add no publication D1/DO calls. Existing identity eligibility/auth-cache reads and audit writes retain their own contracts.

Completed owners are deleted immediately. Every durable acquisition attempt uses one D1 binding operation and one SQL statement, including on contention. The same-statement busy prefilter avoids allocator advancement for a known-live owner; the conflict update takes over an expired owner without deleting it first. Unrelated expired owners remain until hourly maintenance, which deletes at most 20 × 500 owners per run. This bounds maintenance work, not absolute storage during an arbitrary burst or outage. Payload and expired-proof pruning have independent 20 × 500 budgets and never reset the sequence or delete live ownership.

Native local D1 measurements for a short body fill, including the final renewal, were four binding operations / four SQL statements and 13 rows read / 11 written (empty owner backlog). Acquisition leaves an unrelated expired backlog intact; hourly maintenance prunes it. The former inline GC ran 778,116 times for 4,813 deleted rows and 399,365 total D1 ms in the three-day September 23, 2026 insights sample. Proof warming measured four binding operations / four statements and 14 rows read / 10 written in the same fixture, including acquisition, final renewal, proof write and completion; long observations add one renewal statement per active owner every three seconds. A 3.2-second renewable body fill measured five binding operations / five statements, 15 rows read / 13 written, including its periodic and final renewals. These are local runtime counters and logical calls, not hosted D1 billing or a throughput claim. See operations for rollout, restore, and backlog monitoring requirements.

Hits are still audited, with the cached identity attributed. Each audit row records cache status as hit, stale, miss, bypass, or unknown, which powers octopool stats and the dashboard hit-rate/top-route views. Coalesced followers are marked separately. Stats count fresh and stale hits as cache_served_responses, including bodies reused after a successful 304 refresh. Revalidated hits measure body reuse, not avoided upstream requests or saved GitHub quota. Miss and bypass rows count as uncached_outcomes, including failed requests and local fallbacks. The body-reuse rate retains the historical cache_hit_rate field; its eligible variant excludes failed misses and deliberate local fallback responses. Successful 304 refreshes retain hit with fallback_reason = cache_revalidated as their distinct audit marker. These are relay-audited outcomes, not actual or avoided GitHub requests: one outcome can include several upstream attempts, and cache serves can require visibility, membership, or revalidation checks. Unknown cache outcomes are counted separately. The deprecated JSON fields saved_github_requests and backend_requests remain historical aliases for cache_served_responses and uncached_outcomes for shipped clients; they do not measure GitHub savings or fetch totals. New consumers must use the canonical fields. Audit backend describes the resource fetch or verifier for that request: anonymous API 200 replacements and 304 validations use github_api, regardless of the cached body's source. Request-only verifier metadata does not alter stored identity, body encoding, delivered status, or the broad relay backend label. A following cache-only hit has no audit backend; an ancillary visibility probe does not supply one or spend pooled identity quota.

Identity availability feedback is separate from cache-source eligibility. Overlapping identity responses merge quota and cooldown observations conservatively in the pool coordinator, including conditional requests that bypass cache storage. A delayed success cannot reopen a known exhausted window or shorten a live cooldown. Fresh cache reuse still checks active route eligibility and public proof; see identity feedback.

#Public-repo guard

The shared cache and pooled responses are public-repository only. Before any repo response is served or published, ensurePublicGitHubRepo confirms the repo is public. On the pooled identity miss path, a fresh negative proof is rejected first, then the proof owner may run its identity fetch concurrently with the guard; followers wait for the coordinated verdict. Pool tokens may therefore perform one speculative resource read before visibility is proven. Its response stays request-local until the guard succeeds: body, headers, status, errors, and validators cannot reach callers, cache storage, publication waiters, or stale recovery early. A guard failure wins over an upstream success or failure, aborts in-flight reads, and fails owned body fills. Queued work cannot dispatch after denial; late ownership grants are released. Only quota headers may update identity rate state; denied statuses and error classifications do not create cooldowns. Both operations reuse the request's single backend admission permit. Local/anonymous fallbacks, cached-candidate validation, revalidation, proof coordination, and proof TTLs retain their existing order and rules.

  • An unauthenticated GET /repos/{owner}/{repo} is made against GitHub.
  • If OCTOPOOL_GITHUB_ORG_TOKEN is configured, that server-side token is used for the check to avoid shared unauthenticated GitHub quota; Octopool still requires the response body to say private: false.
  • 404 or private !== false → 403 repo_not_public.
  • Definitive non-public results are cached separately for PUBLIC_REPO_NEGATIVE_TTL_SECONDS (default 3600s), avoiding repeated GitHub checks while never authorizing cached repository content. Rate limits, upstream failures, and inconclusive page checks are not cached as negative proofs.
  • If both authenticated and anonymous API checks are rate-limited or unavailable, Octopool can prove visibility from GitHub's public repository page marker without an API token.
  • A successful anonymous request for a direct repository resource is also accepted as the live public proof, so a cache miss does not need a second GitHub metadata request. Search responses still run an explicit visibility check because an empty result does not prove that a repo: qualifier names a public repository; token-free-only shaped search uses the public repository page marker directly.
  • A successful public check is recorded in github_public_repo_proofs with a TTL (PUBLIC_REPO_TTL_SECONDS, default 30s; the hosted deployment sets 900s) and the edge cache; subsequent cache hits reuse the fresh proof instead of re-hitting GitHub. A proof refresh stalls concurrent requests for that repo behind one probe, so a short TTL puts a periodic GitHub round trip on the cache-hit path — the trade against it is how long a repo that flips private can keep serving already-cached content.

#Historical proof during outages

If the live public check fails with a 5xx, or a 403 with x-ratelimit-remaining: 0 (secondary rate limit), the guard may fall back to a previously recorded proof that was captured close to the cache entry's creation time (within 5s) and has not expired. There is no post-expiry grace window: a proof whose expiry equals or precedes the current time is rejected immediately with repo_public_check_failed. This lets cached public data keep serving through transient GitHub failures without ever relaxing the private-repo block — a hard 404/private response always denies.

#Schema

  • github_cache_entries — cache key, pool, method, path, query/headers JSON, route key/kind, status, response headers JSON, body JSON, body encoding, source identity, created/fresh/stale expiration timestamps and internal publication receipt (migrations 0002, 0011, 0020).
  • cache_publication_owners — live/abandoned capabilities, global AUTOINCREMENT fence, unique epoch/resource, indexed D1-clock expiry (0020); retain its sqlite_sequence.
  • github_run_list_items — bounded REST run-list membership, atomically maintained with body publication and indexed by pool/repo/run (0024).
  • github_public_repo_proofs — epoch-isolated positive/negative evidence, immutable timestamps and internal publication receipt (0020). Legacy github_public_repos is ignored by new readers.
  • github_pr_state_proofs — short-lived validated PR head/state discriminators for state-scoped PR subresource cache keys (migration 0006).
  • audit_events.cache_status / audit_events.cacheable — per-request cache metrics (migration 0005).
  • audit_events.fallback_reason / audit_events.coalesced — local fallback classification and duplicate-fill telemetry (migration 0009).
  • audit_events.backend — bounded upstream classification (github_web, github_api, or github_identity) for route-level stats (migration 0013).
  • audit_events.requested_max_age — parsed caller cache-age bound in seconds; 0 requires upstream validation except for proven immutable hits, positive values bound reuse, and NULL means no parsed bound (including older audit rows). Available after migration 0021.
  • ACTIONS_LOGS R2 binding (octopool-actions-logs) — raw terminal Actions log objects; no D1 migration is required.

Upstream credentials are never written to either cache. Internal publication capability tokens are stored only in ownership/receipt metadata; they are not public response fields.