Skip to content

forge-cache

Provenance

  • Source: .spec/spexcode/spec-forge/forge-cache/spec.md
  • Source SHA-256: 9a64338f6a8234a35494523a5d4ff671408bca0ac1520188b61f83025b2696fc

Keeping the [[links]] view fresh — incrementally, or live for a dashboard — is not a product choice. (The node is named for what it IS — the forge-side cache; the bare word freshness belongs to the eval axis — a reading's staleness against code — and means only that everywhere else in the tree.) resolveLinks(issues, prs, nodeIds) is already a pure function, so the problem is the classic one of incremental view maintenance: keep output = f(state) current as state changes, instead of paying for a cold full pull on every look. This node owns that deterministic core; it sits beside [[links]] and, like it, is host-agnostic.

What is incremental is the fetch, never the resolution. resolveLinks is microsecond-cheap and pure, so the cache recomputes the whole view on every read rather than maintain a second, incremental resolution path that could disagree with the full one. The cache adds freshness; it never adds a rival answer.

State, delta, view.

  • State = the cached open-issue set + open-PR set (node ids stay git-local, from loadSpecs).
  • Delta = one observed change, the single currency every source emits: an upsert (the new object, still open) or a remove (it left the open set — closed, merged, deleted, or its Spec: marker dropped). apply folds one delta in, keyed by number, so it is idempotent and order-tolerant — a duplicated or out-of-order delta re-sets the same key; a remove of an absent key is a no-op.
  • View = resolveLinks over the cached set. The cache also exposes the raw set itself (state()), because one consumer — the unified Issue port (spec-cli's [[issues]]) — needs every cached issue, linked or not, to map onto the one Issue type; resolution stays the only derived view, so there is still no second answer to disagree with the full one.

The state also carries a monotonic content revision. It advances only when an upsert, removal, or reconcile changes the cached objects; duplicate deltas and an equal reconcile leave it unchanged. A consumer that publishes a snapshot alongside this state carries that revision, so a reconcile finishing after a background read cannot leave its published projection permanently behind the resident cache: it is a lower bound on the next publication, not permission to poll old rows. The revision is a freshness carrier, never a second resolution path. The deterministic product proof holds the first driver response until that snapshot is published, then releases seven GitLab-shaped rows and reads the same API again; this proves the late-publication race without standing in for a credentialed live-host probe.

Reconcile is the source of truth; sources are only hints. A live source (an ETag-conditional poll, or a forge webhook) may drop, duplicate, or re-order deltas, so it is never trusted as a clean stream. Correctness is restored by reconcile — a full read through the [[port]] that overwrites the cached set wholesale. The invariant the whole design rests on, and the one this node proves:

after reconcile(), view() equals a cold full pull by construction; and a delta stream that represents a set of changes leaves the cache identical to a reconcile of that final state.

So any number of live sources can only ever leave the cache temporarily ahead of the last reconcile, never durably wrong — the invariant holds by construction: a delta stream representing a set of changes leaves the cache identical to a reconcile of that final state, and reconcile() makes view() equal a cold full pull. (Verifying this is the job of the test framework — see [[spec-eval]] — not an ad-hoc script.)

The read-only contract holds unchanged: the cache caches a read of the forge; it never writes a node's version or status (that stays git-derived — see [[spec-forge]]).

The first delta source is live: the updated-since window — the [[port]]'s optional incremental read (listIssuesSince) feeds applyIssues (an upsert merge: an issue never leaves the set, a closed one updates in place) while the small open-PR list is simply re-set whole. The resident cycle is incremental-first: after the seeding reconcile, each TTL tick fetches only that window (normally one page) instead of re-listing the world, and a periodic full reconcile stays the backstop for what an update window cannot see (deleted/transferred issues) — exactly the "sources are hints, reconcile is truth" contract above. Still future siblings: an ETag/If-None-Match poller (free 304s) and a webhook receiver pushing the same delta shape; both plug into apply, neither changes it.