← back to Cli Printing Press
feat(cli): add machine-owned freshness coverage (#249)
4291b3b96f9ddc4306e101d88588f2edd0a41e5b · 2026-04-23 15:07:48 -0700 · Trevin Chow
* feat(cli): add machine-owned freshness coverage
Register custom store-backed command paths for cache freshness, expose generated freshness metadata at meta.freshness, and document the source-selection contract for generated CLIs.
Runtime verify now reports whether generated CLIs have the freshness registry, helper surface, metadata, and live-mode bypass expected by the contract.
* fix(cli): preserve freshness source semantics
Keep auto-mode reads API-first so filtered and scoped commands do not return unfiltered local rows. Honor freshness env opt-out before opening the store and reject custom freshness command paths that collide with generated resource reads.
Files touched
M AGENTS.mdM docs/PIPELINE.mdA docs/plans/2026-04-23-001-feat-machine-owned-freshness-for-store-backed-clis-plan.mdM internal/generator/generator.goM internal/generator/generator_test.goM internal/generator/templates/auto_refresh.go.tmplM internal/generator/templates/cliutil_freshness.go.tmplM internal/generator/templates/cliutil_freshness_test.go.tmplM internal/generator/templates/data_source.go.tmplM internal/generator/templates/helpers.go.tmplM internal/generator/templates/readme.md.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/skill.md.tmplM internal/generator/validate.goM internal/generator/validate_test.goM internal/pipeline/runtime.goM internal/pipeline/runtime_test.goM internal/spec/spec.goM internal/spec/spec_test.go
Diff
commit 4291b3b96f9ddc4306e101d88588f2edd0a41e5b
Author: Trevin Chow <trevin@trevinchow.com>
Date: Thu Apr 23 15:07:48 2026 -0700
feat(cli): add machine-owned freshness coverage (#249)
* feat(cli): add machine-owned freshness coverage
Register custom store-backed command paths for cache freshness, expose generated freshness metadata at meta.freshness, and document the source-selection contract for generated CLIs.
Runtime verify now reports whether generated CLIs have the freshness registry, helper surface, metadata, and live-mode bypass expected by the contract.
* fix(cli): preserve freshness source semantics
Keep auto-mode reads API-first so filtered and scoped commands do not return unfiltered local rows. Honor freshness env opt-out before opening the store and reject custom freshness command paths that collide with generated resource reads.
---
AGENTS.md | 1 +
docs/PIPELINE.md | 5 +
...e-owned-freshness-for-store-backed-clis-plan.md | 335 +++++++++++++++++++++
internal/generator/generator.go | 115 +++++--
internal/generator/generator_test.go | 54 +++-
internal/generator/templates/auto_refresh.go.tmpl | 94 ++++--
.../generator/templates/cliutil_freshness.go.tmpl | 13 +
.../templates/cliutil_freshness_test.go.tmpl | 25 ++
internal/generator/templates/data_source.go.tmpl | 35 ++-
internal/generator/templates/helpers.go.tmpl | 16 +
internal/generator/templates/readme.md.tmpl | 17 ++
internal/generator/templates/root.go.tmpl | 3 +-
internal/generator/templates/skill.md.tmpl | 15 +
internal/generator/validate.go | 6 +
internal/generator/validate_test.go | 15 +
internal/pipeline/runtime.go | 63 ++++
internal/pipeline/runtime_test.go | 40 +++
internal/spec/spec.go | 44 ++-
internal/spec/spec_test.go | 40 +++
19 files changed, 879 insertions(+), 57 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index 2a1410be..7703dce1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -92,6 +92,7 @@ Key terms used throughout this repo. Several have overloaded meanings — the gl
| **cliutil** | The generator-owned Go package emitted into every printed CLI at `internal/cliutil/`. Houses shared helpers meant for agent-authored novel code to import: `cliutil.FanoutRun` for aggregation commands (per-source error collection, bounded concurrency, source-order output), `cliutil.CleanText` for HTML/JSON-LD text normalization. **Generator-reserved namespace** — agents authoring novel code in Phase 3 must not put their code in `internal/cliutil/` or name their own helpers that collide with cliutil's exports. |
| **shipcheck** | The three-part verification block that gates publishing: dogfood + verify + scorecard, run together. All three must pass before a printed CLI ships. |
| **scorecard** / **scoring** | Two-tier quality assessment with a 50/50 weighted composite. Tier 1: infrastructure (16 string-matching dimensions, raw max 160, normalized to 0-50). Tier 2: domain correctness (7 semantic dimensions, raw max 60 when live verify ran, normalized to 0-50). Total /100 with letter grades. Source of truth: `internal/pipeline/scorecard.go` (tier1Max / tier2Max). Subcommand: `printing-press scorecard`. |
+| **machine-owned freshness** | Opt-in freshness contract for store-backed printed CLIs using `cache.enabled`. Covered command paths map to syncable resources; in `--data-source auto` they may run a bounded pre-read refresh before serving local data. `--data-source local` never refreshes, `--data-source live` must not mutate the local store, and env opt-out only disables the freshness hook. This is current-cache freshness, not a guarantee of full historical backfill or API-specific enrichment. |
| **doctor** | Self-diagnostic command shipped inside every printed CLI for end-users to run. Checks environment, auth config, and connectivity at the user's runtime. Unlike dogfood (which validates at generation time), doctor runs post-install. |
| **auth doctor** | Subcommand on the printing-press binary (`printing-press auth doctor`). Scans every installed printed CLI's `tools-manifest.json` under `~/printing-press/library/<api>/` and reports env-var status (ok / suspicious / not_set / no_auth / unknown) with redacted fingerprints. Diagnostic only — never gates, never probes the network. Lives in `internal/authdoctor/`. |
| **mcp-audit** | Subcommand on the printing-press binary (`printing-press mcp-audit`). Walks every library CLI and reports transport, tool-design, and per-CLI recommendations for the `mcp:` spec surface introduced in the U1-U3 machine work (remote transport, intent tools, code-orchestration). Diagnostic only — exit 0 regardless of findings. Supports `--json` for machine-readable output. |
diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md
index 6db5e4f5..43c965d6 100644
--- a/docs/PIPELINE.md
+++ b/docs/PIPELINE.md
@@ -101,6 +101,11 @@ Outputs:
- Generated CLI source tree under the API's output directory
- Working CLI binary for the target API
+Freshness ownership:
+- Store-backed CLIs that opt into `cache.enabled` get a generated command-path freshness registry. Generated syncable resource read commands are registered automatically; hand-authored commands must opt in explicitly with command-path coverage.
+- `--data-source auto` may run a bounded pre-read refresh for registered paths. `--data-source local` never refreshes. `--data-source live` reads the API and must not mutate the local store.
+- Freshness metadata belongs in the existing JSON provenance envelope at `meta.freshness`. It describes current-cache freshness for the covered path only; it must not be described as full historical backfill or API-specific enrichment.
+
Gates:
- All seven generator quality gates pass: `go mod tidy`, `go vet`, `go build`, binary build, `--help`, version, `doctor`
diff --git a/docs/plans/2026-04-23-001-feat-machine-owned-freshness-for-store-backed-clis-plan.md b/docs/plans/2026-04-23-001-feat-machine-owned-freshness-for-store-backed-clis-plan.md
new file mode 100644
index 00000000..81680aec
--- /dev/null
+++ b/docs/plans/2026-04-23-001-feat-machine-owned-freshness-for-store-backed-clis-plan.md
@@ -0,0 +1,335 @@
+---
+title: "feat: Machine-Owned Freshness for Store-Backed Printed CLIs"
+type: feat
+status: active
+date: 2026-04-23
+---
+
+# feat: Machine-Owned Freshness for Store-Backed Printed CLIs
+
+## Overview
+
+The Product Hunt follow-up PR surfaced a useful distinction:
+
+1. Some of the work is **Product Hunt specific** and should stay in that printed CLI.
+2. Some of the work is a **machine gap**: store-backed printed CLIs need a better default story for freshness ownership, cold-start behavior, and agent-facing observability.
+
+This plan focuses on the machine. The goal is not to make every printed CLI behave like Product Hunt. The immediate goal is narrower: make the existing Printing Press freshness machinery usable by generated read commands and explicitly registered custom store-backed commands, so future printed CLIs start with a better freshness baseline instead of reimplementing small wrappers one CLI at a time.
+
+The core insight is broad, but v1 should stay conservative: **when a printed CLI has a local store and a command path has explicitly opted into machine-managed freshness, the CLI should own the bounded freshness check before serving that command.** API-specific enrichment and backfill tiers may still require per-CLI work, and machine-wide scoring or new schema concepts should wait until the helper contract is proven in more than one printed CLI.
+
+## Problem Frame
+
+The Product Hunt PR tried to make `producthunt-pp-cli` "self-warming" by adding:
+
+- automatic Atom sync before read commands
+- `search --enrich` to top up thin local results from GraphQL
+- explicit GraphQL backfill and resume flows
+
+That direction has merit. Agents and downstream tools should not have to know "run sync every 24h, then query the store." They should be able to call the semantic read command and let the CLI decide whether the store is fresh enough.
+
+But the PR also showed why this must be separated carefully:
+
+- **The machine already has part of this capability.** `spec.Cache`, `cliutil_freshness.go.tmpl`, `auto_refresh.go.tmpl`, and the root pre-run hook already provide an opt-in stale-cache refresh path for generated store-backed CLIs.
+- **The current machine capability is too narrow and too invisible.** It is positioned as "cache auto-refresh," not as a first-class agent-facing freshness contract for store-backed read commands.
+- **Custom hand-authored commands sit outside the generic story.** Product Hunt had to invent `autoWarm()` and `AutoSyncMeta` because the generated freshness helpers are not exposed as an ergonomic extension seam for custom local-store commands.
+- **The machine does not expose a clear coverage registry for custom commands.** Generated commands have `readCommandResources`, but hand-authored local-store commands do not have a canonical way to register "this command path reads these resources and may refresh them."
+- **Generated docs do not yet express this nuance.** The machine can produce or permit claims like "self-warming" without specifying which commands are warmed, what source they use, and what happens on explicit live-only paths.
+
+If we do nothing at the machine level, future store-backed CLIs will keep rediscovering the same pattern in custom code:
+
+- pre-read freshness check
+- bounded fail-soft refresh
+- opt-out flag/env
+- stale-vs-never-synced distinction
+- hidden coupling between read commands and sync/backfill layers
+- missing or inconsistent JSON metadata about the refresh decision
+
+That is exactly the kind of repeated work the Printing Press should absorb.
+
+## Why This Matters Broadly
+
+This pattern is valuable beyond Product Hunt for any printed CLI that:
+
+- syncs API data into SQLite for offline reads, search, or insights
+- derives agent-useful views from historical snapshots
+- uses a local store as the main interface instead of raw passthrough endpoints
+- has cheap incremental refresh but expensive historical hydration
+
+Broad benefits, if the v1 helper contract proves itself across more than Product Hunt:
+
+- **Better first-run usability.** Agents can issue the meaningful read command instead of learning a vendor-specific "sync first" ritual.
+- **Less duplicated orchestration in downstream tools.** Skills, MCP hosts, and scripts no longer each invent their own stale-store policy.
+- **More honest CLI contracts.** A printed CLI can explicitly say what it owns: freshness of current cache, not necessarily full historical completeness.
+- **Safer defaults for agents.** A bounded fail-soft refresh is better than empty-store surprises or unbounded background sync.
+- **A cleaner machine/printed split.** The machine provides bounded freshness for declared command paths; individual printed CLIs can layer API-specific enrichment or backfill when justified.
+
+## Requirements Trace
+
+- R1. Treat store-backed read freshness for explicitly registered command paths as a first-class machine capability, not an ad hoc per-CLI trick.
+- R2. Build on the existing `spec.Cache` / auto-refresh machinery rather than introducing a second competing abstraction.
+- R3. Expose a reusable extension seam so hand-authored read commands in printed CLIs can participate in the same freshness behavior and metadata contract as generated commands.
+- R4. Distinguish baseline freshness from optional historical backfill or enrichment. The machine must not overclaim that a CLI is "fully warm" when only declared command paths are covered.
+- R5. Preserve command semantics for source selection. `--data-source live` must not silently mutate the local store first; `--data-source local` must not refresh; `--data-source auto` may perform bounded refresh when the command path is registered.
+- R6. Keep freshness opt-out separate from source selection. Env opt-out and any future `--no-auto-refresh` disable the pre-read freshness hook while leaving `--data-source` semantics intact.
+- R7. Make freshness decisions observable to agents in a stable JSON shape when JSON output uses the generated provenance envelope.
+- R8. Update generated README/SKILL templates so the machine accurately describes freshness ownership, opt-outs, and limitations for store-backed CLIs.
+- R9. Verify the capability based on actual runtime behavior for CLIs with a machine-testable freshness fixture. Do not score generic cross-CLI freshness ownership from fixture-only evidence.
+
+## Scope Boundaries
+
+- Not generating Product Hunt's GraphQL enrichment and backfill stack for every CLI.
+- Not assuming every store-backed CLI should auto-refresh by default. This remains opt-in and context-sensitive.
+- Not defining a universal "enrichment API" abstraction. Topic-aware top-up, budget handling, and OAuth registration are API-specific.
+- Not forcing every historical or insight command to self-hydrate. Some commands should still require explicit sync/backfill if the cost profile is high.
+- Not changing the machine to claim completeness it cannot provide. "Fresh enough to serve current reads" is different from "historically complete for 30 days."
+- Not introducing a general read-model schema in v1. V1 uses command-path coverage because the generator already has that concept.
+- Not adding new scorecard weight in v1. Scoring can follow after at least two printed CLIs use the helper contract and verify can test it end to end.
+
+## Separation: Product Hunt Specific vs Machine Generalizable
+
+### Product Hunt-Specific Work
+
+These should stay in the printed CLI unless a later cross-CLI pattern proves they generalize:
+
+- Atom as the cheap default read surface and GraphQL as the expensive historical surface
+- OAuth app registration flow against Product Hunt's `client_credentials` token endpoint
+- Product Hunt budget tracking and resume behavior around GraphQL complexity limits
+- Topic-aware `search --enrich` heuristics, including time window and client-side filtering
+- Whether GraphQL backfill should write only `posts`, also write synthetic `snapshots`, or both
+- Product Hunt command-specific semantics such as `today`, `trend`, and snapshot-derived ranking history
+
+### Machine-Generalizable Work
+
+These should move into the Printing Press because they help many future store-backed CLIs:
+
+- A first-class freshness contract for explicitly registered store-backed read command paths
+- Generated helpers for deciding whether a read path should refresh before serving
+- Stable agent-facing metadata describing the freshness decision
+- Clear separation between fresh cache, stale cache, no store, source selection, and freshness opt-out
+- A command-path registry so the machine knows which generated and custom commands are covered by which resources
+- Template support so the generated README and SKILL describe the contract accurately
+- Runtime verify coverage for freshness-owned read paths when the CLI declares a machine-testable fixture
+
+## Existing Machine Surface to Reuse
+
+The machine already contains the right seed crystals:
+
+- `internal/spec/spec.go` - `CacheConfig` already models stale-after, refresh timeout, per-resource thresholds, and env opt-out.
+- `internal/generator/templates/cliutil_freshness.go.tmpl` - already computes freshness decisions from `sync_state`.
+- `internal/generator/templates/auto_refresh.go.tmpl` - already performs bounded fail-soft refreshes for generated read commands.
+- `internal/generator/templates/root.go.tmpl` - already hooks auto-refresh into persistent pre-run for generated read commands.
+
+The plan should therefore **extend and rename the mental model**, not replace it:
+
+- "cache auto-refresh" should become "machine-owned read freshness for registered store-backed command paths"
+- the helper surface should be reusable by custom commands
+- the docs and verification layer should understand what the capability actually guarantees
+
+## Key Technical Decisions
+
+- **KTD-1: Build on `spec.Cache`; do not invent `spec.SelfWarming`.** The current machine already has a freshness abstraction. Renaming the concept at the documentation/review level is cheaper and safer than introducing a parallel config block with overlapping semantics.
+
+- **KTD-2: The machine capability is baseline freshness, not generic enrichment/backfill.** Auto-refresh is the default machine leg for registered command paths. Narrow enrichment and deep backfill remain optional printed-CLI extensions.
+
+- **KTD-3: V1 freshness coverage is keyed by command path.** The existing machine already maps generated command paths to resource names in `readCommandResources`. V1 extends that registry so custom commands can opt in with explicit `command path -> resources` entries. "Read model" remains a design note, not a spec concept, until a second printed CLI proves command-path coverage is insufficient.
+
+- **KTD-4: Explicit live-mode commands must not silently mutate local state.** The generated freshness hook must respect bypass semantics consistently. Product Hunt's `today --live` regression is exactly what the machine should prevent.
+
+- **KTD-5: Freshness metadata extends the existing generated provenance envelope.** The current data-layer envelope is `{"results": <payload>, "meta": {...}}`; freshness metadata lives at `meta.freshness`. Commands that still emit bare arrays or custom JSON must opt into a generated envelope helper before claiming JSON freshness metadata. V1 does not silently wrap arbitrary custom outputs because that would be an output contract change.
+
+- **KTD-6: Hand-authored commands need the same helper contract as generated commands, but only when they opt in.** The machine should emit a small reusable helper surface that custom commands can call without rewriting freshness logic, output metadata plumbing, and bypass checks. Custom commands that do not register a command path remain outside freshness coverage and outside freshness docs/verify claims.
+
+## Open Questions
+
+### Resolve During Planning / Design
+
+- Should generated bypass control be a dedicated `--no-auto-refresh` flag, continue as env-only opt-out, or both?
+- Should the first pilot require two CLIs before adding scorecard treatment, or is Product Hunt plus one generated fixture enough?
+
+### Defer to Implementation
+
+- Exact registration shape for custom command paths: generated map extension, spec-side `extra_commands`, or a small hand-authored Go registration helper
+- Whether verify should simulate stale-store transitions via fixture seeding or manipulated `sync_state`
+- Whether a later scorecard change belongs in a new dimension or the existing data-layer rubric after adoption is demonstrated
+
+## Runtime Semantics Matrix
+
+V1 must make the first-run and bypass behavior explicit so implementers do not infer different contracts.
+
+| State | `--data-source auto` | `--data-source local` | `--data-source live` |
+|-------|----------------------|-----------------------|----------------------|
+| Fresh store | Serve local data; `meta.freshness.decision = "fresh"` | Serve local data; no refresh | Bypass freshness hook; call live path only |
+| Stale `sync_state` | Run bounded refresh, then serve local data; if refresh fails, serve stale data with warning/meta error | Serve stale local data; no refresh | Bypass freshness hook; call live path only |
+| Missing DB or missing `sync_state` | Run bounded initial refresh only when the command path is covered and a sync path exists; otherwise return not-hydrated metadata | Return not-hydrated/local-empty behavior; no refresh | Bypass freshness hook; call live path only |
+| Zero-row store after refresh | Return normal empty result plus freshness metadata | Return normal empty local result | Return live result if the command has a live path |
+| Env/future flag freshness opt-out | Skip freshness hook and serve according to command's existing `auto` behavior | Same as local | Same as live |
+
+The important distinction: source selection chooses where the command reads from; freshness opt-out only disables the pre-read refresh hook.
+
+## Implementation Units
+
+- [x] **Unit 1: Reframe the freshness model around command-path coverage**
+
+**Goal:** Make the existing `spec.Cache` machinery the canonical machine abstraction for store-backed read freshness, with command-path coverage as the v1 source of truth.
+
+**Requirements:** R1, R2, R4, R5, R6
+
+**Files:**
+- Modify: `internal/spec/spec.go`
+- Modify: `internal/spec/spec_test.go`
+- Modify: `docs/PIPELINE.md`
+- Modify: `AGENTS.md`
+
+**Approach:**
+- Keep the current `cache.enabled`, stale-after, refresh-timeout, per-resource thresholds, and env opt-out semantics intact.
+- Define command-path coverage as the v1 authoritative unit: each covered command path maps to the resource names the freshness hook may refresh.
+- Generated commands continue to populate coverage from existing `SyncableResources`.
+- Custom commands can opt in only through an explicit registry entry. The plan should pick the least invasive implementation shape during this unit: either an extension of generated `readCommandResources`, a small `RegisterFreshnessCommand(path, resources...)` helper, or `extra_commands` metadata if that already fits the repo's patterns.
+- Document that "read model" is deferred terminology. V1 avoids a new read-model schema until command-path coverage proves insufficient.
+- Separate source-selection semantics from freshness opt-out semantics:
+- `--data-source auto`: covered command paths may perform bounded refresh.
+- `--data-source live`: no pre-read local-store mutation.
+- `--data-source local`: no refresh; serve local data or report not hydrated.
+- env opt-out / future `--no-auto-refresh`: disables the pre-read freshness hook while preserving the selected data source.
+
+**Test scenarios:**
+- Spec with current `cache.enabled` shape still validates unchanged.
+- Generated command-path coverage validates for syncable resources.
+- Custom command coverage validates only when each referenced resource is syncable or otherwise explicitly declared as refreshable.
+- Coverage declaration for an unknown command path or unknown resource fails with a clear validation error.
+- `--data-source` semantics and freshness opt-out semantics are documented as separate controls.
+- Docs review: `AGENTS.md` and `docs/PIPELINE.md` describe freshness ownership without implying universal historical completeness.
+
+- [x] **Unit 2: Add the missing custom-command helper surface**
+
+**Goal:** Provide a shared helper contract so custom hand-authored commands can participate in the same freshness and metadata behavior as generated commands.
+
+**Requirements:** R3, R5, R6
+
+**Files:**
+- Modify: `internal/generator/templates/cliutil_freshness.go.tmpl`
+- Modify: `internal/generator/templates/cliutil_freshness_test.go.tmpl`
+- Modify: `internal/generator/templates/auto_refresh.go.tmpl`
+- Modify: `internal/generator/templates/root.go.tmpl`
+- Modify: `internal/generator/generator.go`
+
+**Approach:**
+- Preserve the existing decision helper where possible. This unit should add the missing reusable pieces rather than restructure the full helper layer.
+- Add or expose a bypass-aware refresh wrapper that custom commands can call with a command path or resource list.
+- Add a generated metadata type with stable fields such as `decision`, `ran`, `reason`, `resources`, `elapsed_ms`, `error`, and `source`.
+- Add a JSON metadata attachment helper for commands using the generated provenance envelope. The helper writes freshness metadata at `meta.freshness`.
+- Commands that emit bare arrays or custom JSON must opt into the generated envelope helper before claiming JSON freshness metadata. V1 should not silently wrap arbitrary custom outputs.
+- Make the helper callable from hand-authored commands in printed CLIs without requiring them to duplicate store-open, freshness-decision, and metadata-plumbing logic.
+- Enforce bypass semantics: explicit live-only modes skip freshness-triggered mutation.
+- Keep generated command behavior backward compatible for CLIs already using `cache.enabled`.
+
+**Test scenarios:**
+- Generated read command in `data-source auto` mode refreshes stale data and attaches freshness metadata in JSON mode.
+- Generated read command in `data-source local` mode does not refresh and reports the correct bypass/meta reason.
+- Generated read command in `data-source live` mode does not refresh or mutate the store first.
+- Custom-command fixture can call the emitted helper and receives the same metadata shape as a generated command.
+- Bare-array custom command fixture does not claim `meta.freshness` until it opts into the generated envelope helper.
+- Refresh failure becomes a warning plus stale serve, not a hard command failure.
+
+- [x] **Unit 3: Teach generated docs to describe freshness honestly**
+
+**Goal:** Ensure the machine-generated README/SKILL describe freshness ownership, opt-outs, and limits accurately for store-backed CLIs.
+
+**Requirements:** R4, R7
+
+**Files:**
+- Modify: `internal/generator/templates/readme.md.tmpl`
+- Modify: `internal/generator/templates/skill.md.tmpl`
+
+**Approach:**
+- Add conditional doc language for store-backed CLIs with machine-owned freshness:
+ - what the CLI refreshes automatically
+ - when it will not refresh
+ - what "fresh" means
+ - what still requires explicit sync/backfill
+- Prevent boilerplate claims that imply universal warmth or historical completeness.
+- Generate docs from the same command-path coverage registry used by runtime behavior.
+- Defer `skills/printing-press*.md` review-pipeline automation until the freshness contract and JSON metadata shape are stable in at least one generated CLI and one custom-command CLI.
+
+**Test scenarios:**
+- Store-backed CLI with freshness enabled gets README/SKILL text that mentions bounded auto-refresh and opt-out paths.
+- CLI without freshness enabled does not receive self-warming language.
+- CLI with optional explicit backfill documents it as additive, not part of the baseline freshness contract.
+- Generated docs list covered command paths or command families derived from the registry and do not claim coverage for unregistered custom commands.
+
+- [x] **Unit 4: Verify machine-owned freshness behavior**
+
+**Goal:** Make freshness behavior a tested machine capability rather than a template best-effort.
+
+**Requirements:** R9
+
+**Files:**
+- Modify: `internal/pipeline/verify.go`
+- Modify: `internal/pipeline/runtime.go`
+- Modify: relevant `internal/pipeline/*_test.go` files covering runtime verify and stale-store setup
+- Modify: any relevant verify fixture builders or test helpers for store-backed CLIs
+
+**Approach:**
+- Add verify coverage for freshness-owned read paths:
+ - stale store triggers bounded refresh
+ - explicit live/local bypass works
+ - JSON mode exposes the freshness decision
+- Add a runtime verify fixture contract for freshness-aware CLIs. The fixture must declare how to seed the store, how to mark resources stale, and which covered command path should be exercised.
+- Thread freshness verify results into the report contract so scorecard can consume them later.
+- Do not add scorecard weight in v1. The first proof target is runtime behavior, not a new quality grade.
+- Split the proof strategy:
+- deterministic generator/unit tests prove helper mechanics
+- runtime verify proves behavior only for CLIs that declare a machine-testable freshness fixture
+- later scorecard work can use these runtime results after the contract is adopted by at least two printed CLIs
+
+**Test scenarios:**
+- Verify fixture with stale `sync_state` proves a read command refreshes before serving.
+- Verify fixture with `data-source live` proves no pre-read store mutation occurs.
+- Verify fixture with `data-source local` proves no refresh occurs and the command reports local/not-hydrated behavior.
+- Verify fixture with refresh failure proves stale serve plus warning behavior.
+- Verify report distinguishes CLIs that implement the freshness contract from those that only have a local store.
+
+## Printed-CLI Follow-On Work After Machine Changes
+
+These are not machine units, but they should be tracked explicitly so we do not blur the boundary:
+
+### Product Hunt follow-on fixes
+
+- Fix `today --live` so explicit live mode never performs implicit store mutation first.
+- Fix `auth logout` to clear all saved OAuth credentials, not only access tokens.
+- Either attach freshness metadata consistently or stop claiming it in docs until it exists.
+- Re-scope `search --enrich` claims so they match what one narrow GraphQL page can really recover.
+- Decide whether backfill should populate snapshot-oriented read models or stay limited to entity/search hydration.
+
+### Product Hunt strategic decisions
+
+- Keep the Atom-first baseline and GraphQL-specific historical lift as a printed-CLI policy, not a machine default.
+- If Product Hunt proves a reusable "cheap incremental sync + expensive historical backfill" pattern across multiple CLIs later, revisit a machine abstraction then, with at least two non-Product-Hunt examples.
+
+## System-Wide Impact
+
+- **Machine default improves:** New store-backed CLIs start from a clearer freshness contract for covered command paths instead of each one inventing read-path sync wrappers.
+- **Printed CLI customization remains available:** CLIs can still add API-specific enrichment or backfill layers without fighting the machine.
+- **Agent behavior improves:** Hosts can call semantic read commands with fewer empty-store surprises and with better observability when JSON is enabled.
+- **Review quality improves:** Generated docs become more honest about what "freshness" actually covers. Review-pipeline automation and scorecard changes stay deferred until the contract proves stable.
+
+## Risks & Mitigations
+
+| Risk | Severity | Why it matters | Mitigation |
+|------|----------|----------------|------------|
+| We overfit the machine to Product Hunt's exact architecture | High | Future CLIs inherit the wrong abstraction | Keep enrichment/backfill out of the machine scope; build only the baseline freshness contract |
+| We create a second abstraction next to `spec.Cache` | High | Confusing, duplicative, harder to maintain | Extend `spec.Cache`; do not add a rival config block |
+| Freshness claims still overstate coverage | High | Agents trust docs and fail in non-obvious ways | Require declared coverage and review those claims against command families |
+| Explicit live-only commands still mutate local state | Medium | Behavioral regression and user surprise | Add template-level bypass tests and verify fixtures |
+| Custom commands continue to bypass the machine helper | Medium | Per-CLI drift returns | Emit a small helper surface designed for custom-command adoption and document it in generated code comments |
+| Scorecard rewards fixture-only behavior too early | Medium | Grades imply cross-CLI trust that has not been proven | Defer scorecard weighting until runtime verify succeeds on at least two printed CLIs |
+
+## Exit Criteria
+
+- The machine has one clear freshness abstraction for store-backed CLIs, centered on the existing cache/auto-refresh surface.
+- Generated and explicitly registered custom commands can share a stable freshness helper and JSON metadata contract.
+- README/SKILL language accurately describes freshness ownership and its limits.
+- Verify covers stale-store refresh behavior and bypass semantics.
+- Scorecard and review-pipeline automation are explicitly deferred until the runtime contract has adoption evidence.
+- Product Hunt-specific work is clearly documented as printed-CLI follow-on, not silently absorbed into the machine.
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 0e12b3e9..328a493a 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -573,16 +573,17 @@ type clientTemplateData struct {
// readmeTemplateData wraps APISpec with additional fields for README rendering.
type readmeTemplateData struct {
*spec.APISpec
- Sources []ReadmeSource
- DiscoveryPages []string
- NovelFeatures []NovelFeature
- Narrative *ReadmeNarrative
- ProseName string
- HasDataLayer bool
- HasAsyncJobs bool
- HasWriteCommands bool
- HasAuth bool
- TrafficAnalysis *trafficAnalysisTemplateData
+ Sources []ReadmeSource
+ DiscoveryPages []string
+ NovelFeatures []NovelFeature
+ Narrative *ReadmeNarrative
+ ProseName string
+ HasDataLayer bool
+ HasAsyncJobs bool
+ HasWriteCommands bool
+ HasAuth bool
+ FreshnessCommands []string
+ TrafficAnalysis *trafficAnalysisTemplateData
}
type generatorTemplateData struct {
@@ -613,18 +614,46 @@ func (g *Generator) readmeData() *readmeTemplateData {
}
}
return &readmeTemplateData{
- APISpec: g.Spec,
- Sources: g.Sources,
- DiscoveryPages: g.DiscoveryPages,
- NovelFeatures: g.NovelFeatures,
- Narrative: g.Narrative,
- ProseName: g.proseName(),
- HasDataLayer: g.VisionSet.Store,
- HasAsyncJobs: len(g.AsyncJobs) > 0,
- HasWriteCommands: hasWriteCommands(g.Spec.Resources),
- HasAuth: hasAuth(g.Spec.Auth),
- TrafficAnalysis: g.trafficAnalysisData(),
+ APISpec: g.Spec,
+ Sources: g.Sources,
+ DiscoveryPages: g.DiscoveryPages,
+ NovelFeatures: g.NovelFeatures,
+ Narrative: g.Narrative,
+ ProseName: g.proseName(),
+ HasDataLayer: g.VisionSet.Store,
+ HasAsyncJobs: len(g.AsyncJobs) > 0,
+ HasWriteCommands: hasWriteCommands(g.Spec.Resources),
+ HasAuth: hasAuth(g.Spec.Auth),
+ FreshnessCommands: g.freshnessCommandPaths(),
+ TrafficAnalysis: g.trafficAnalysisData(),
+ }
+}
+
+func (g *Generator) freshnessCommandPaths() []string {
+ if !g.Spec.Cache.Enabled || g.profile == nil {
+ return nil
+ }
+ seen := map[string]struct{}{}
+ var paths []string
+ add := func(path string) {
+ if _, ok := seen[path]; ok {
+ return
+ }
+ seen[path] = struct{}{}
+ paths = append(paths, path)
+ }
+ for _, resource := range g.profile.SyncableResources {
+ prefix := naming.CLI(g.Spec.Name) + " " + resource.Name
+ add(prefix)
+ add(prefix + " list")
+ add(prefix + " get")
+ add(prefix + " search")
}
+ for _, command := range g.Spec.Cache.Commands {
+ add(naming.CLI(g.Spec.Name) + " " + command.Name)
+ }
+ sort.Strings(paths)
+ return paths
}
func (g *Generator) proseName() string {
@@ -901,6 +930,9 @@ func (g *Generator) Generate() error {
if g.profile == nil {
g.profile = profiler.Profile(g.Spec)
}
+ if err := g.validateFreshnessCommandCoverage(); err != nil {
+ return err
+ }
// Detect async-job endpoints once per generation. Results flow into
// per-endpoint template data (for conditional --wait emission) and into
@@ -1550,6 +1582,47 @@ func (g *Generator) Generate() error {
return nil
}
+func (g *Generator) validateFreshnessCommandCoverage() error {
+ if !g.Spec.Cache.Enabled || len(g.Spec.Cache.Commands) == 0 {
+ return nil
+ }
+ syncable := make(map[string]struct{}, len(g.profile.SyncableResources))
+ for _, resource := range g.profile.SyncableResources {
+ syncable[resource.Name] = struct{}{}
+ }
+ for _, command := range g.Spec.Cache.Commands {
+ if _, collides := generatedFreshnessCommandNames(command.Name, syncable); collides {
+ return fmt.Errorf("cache.commands[%s]: command path is already covered by generated resource freshness", command.Name)
+ }
+ for _, resource := range command.Resources {
+ if _, ok := syncable[resource]; !ok {
+ return fmt.Errorf("cache.commands[%s]: resource %q is not syncable and cannot be auto-refreshed", command.Name, resource)
+ }
+ }
+ }
+ return nil
+}
+
+func generatedFreshnessCommandNames(name string, syncable map[string]struct{}) (string, bool) {
+ parts := strings.Fields(name)
+ if len(parts) == 0 {
+ return "", false
+ }
+ if _, ok := syncable[parts[0]]; !ok {
+ return "", false
+ }
+ if len(parts) == 1 {
+ return parts[0], true
+ }
+ if len(parts) == 2 {
+ switch parts[1] {
+ case "list", "get", "search":
+ return strings.Join(parts, " "), true
+ }
+ }
+ return "", false
+}
+
func commandConstructorForTemplate(tmpl string) string {
switch filepath.Base(tmpl) {
case "pm_stale.go.tmpl":
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 310b9def..10b63b33 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -122,7 +122,13 @@ func TestGenerateFreshnessHelperEmitted(t *testing.T) {
// Start from stytch (has resources -> has store) and flip cache on.
apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
require.NoError(t, err)
- apiSpec.Cache = spec.CacheConfig{Enabled: true, StaleAfter: "6h"}
+ apiSpec.Cache = spec.CacheConfig{
+ Enabled: true,
+ StaleAfter: "6h",
+ Commands: []spec.CacheCommand{
+ {Name: "dashboard", Resources: []string{"users"}},
+ },
+ }
outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
gen := New(apiSpec, outputDir)
@@ -143,20 +149,45 @@ func TestGenerateFreshnessHelperEmitted(t *testing.T) {
"var readCommandResources = map[string][]string{",
"func cachePolicy() cliutil.Policy",
"func autoRefreshIfStale(",
+ "func ensureFreshForResources(",
+ "func ensureFreshForCommand(",
"func runAutoRefresh(",
+ `"stytch-pp-cli dashboard": {`,
+ `"users",`,
// Env opt-out is derived at runtime from the CLI name; probe the
// expression that yields e.g. "STYTCH_NO_AUTO_REFRESH".
`strings.ReplaceAll(strings.ToUpper("stytch"), "-", "_") + "_NO_AUTO_REFRESH"`,
} {
assert.Contains(t, src, snippet, "auto_refresh.go missing %q", snippet)
}
+ optOutIndex := strings.Index(src, "env_opt_out")
+ openStoreIndex := strings.Index(src, "store.Open(dbPath)")
+ require.NotEqual(t, -1, optOutIndex, "auto_refresh.go must report env opt-out")
+ require.NotEqual(t, -1, openStoreIndex, "auto_refresh.go must open the store after opt-out checks")
+ assert.Less(t, optOutIndex, openStoreIndex, "env opt-out must be checked before opening/migrating the store")
+
+ dataSource, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "data_source.go"))
+ require.NoError(t, err)
+ assert.NotContains(t, string(dataSource), "freshness_checked",
+ "auto mode must stay API-first because local reads do not apply filters/scopes")
// Root command must wire the hook.
rootSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
require.NoError(t, err)
- assert.Contains(t, string(rootSrc), "autoRefreshIfStale(cmd.Context(), &flags, resources)",
+ assert.Contains(t, string(rootSrc), "flags.freshnessMeta = autoRefreshIfStale(cmd.Context(), &flags, resources)",
"root.go must invoke autoRefreshIfStale from PersistentPreRunE")
+ readme, err := os.ReadFile(filepath.Join(outputDir, "README.md"))
+ require.NoError(t, err)
+ assert.Contains(t, string(readme), "## Freshness")
+ assert.Contains(t, string(readme), "meta.freshness")
+ assert.Contains(t, string(readme), "`stytch-pp-cli dashboard`")
+
+ skill, err := os.ReadFile(filepath.Join(outputDir, "SKILL.md"))
+ require.NoError(t, err)
+ assert.Contains(t, string(skill), "## Freshness Contract")
+ assert.Contains(t, string(skill), "Covered paths:")
+
// Generated helper must compile and its tests must pass end-to-end,
// exercising the sync_state contract against a real SQLite DB.
runGoCommand(t, outputDir, "mod", "tidy")
@@ -164,6 +195,25 @@ func TestGenerateFreshnessHelperEmitted(t *testing.T) {
runGoCommand(t, outputDir, "test", "./internal/cliutil/...")
}
+func TestGenerateFreshnessRejectsGeneratedCommandCollision(t *testing.T) {
+ t.Parallel()
+
+ apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
+ require.NoError(t, err)
+ apiSpec.Cache = spec.CacheConfig{
+ Enabled: true,
+ Commands: []spec.CacheCommand{
+ {Name: "users list", Resources: []string{"users"}},
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ err = gen.Generate()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "already covered by generated resource freshness")
+}
+
// TestGenerateShareEmittedWhenEnabled verifies the end-to-end share
// surface: share package, share commands, and the share subcommand
// registered on the root command. Exercises the generated share_test.go
diff --git a/internal/generator/templates/auto_refresh.go.tmpl b/internal/generator/templates/auto_refresh.go.tmpl
index 39875f52..43a2b44e 100644
--- a/internal/generator/templates/auto_refresh.go.tmpl
+++ b/internal/generator/templates/auto_refresh.go.tmpl
@@ -17,7 +17,8 @@ import (
// readCommandResources maps command paths ({{backtick}}cmd.CommandPath(){{backtick}}) to the
// resource types those commands read. The auto-refresh hook consults this
// map to decide whether to refresh the local cache before serving.
-// Populated from SyncableResources at generation time.
+// Populated from generated syncable resource commands and any custom
+// command-path coverage declared in spec.Cache.Commands.
var readCommandResources = map[string][]string{
{{- range .SyncableResources}}
"{{$.Name}}-pp-cli {{.Name}}": {"{{.Name}}"},
@@ -25,6 +26,13 @@ var readCommandResources = map[string][]string{
"{{$.Name}}-pp-cli {{.Name}} get": {"{{.Name}}"},
"{{$.Name}}-pp-cli {{.Name}} search": {"{{.Name}}"},
{{- end}}
+{{- range .Cache.Commands}}
+ "{{$.Name}}-pp-cli {{.Name}}": {
+{{- range .Resources}}
+ "{{.}}",
+{{- end}}
+ },
+{{- end}}
}
// cachePolicy returns the cache freshness policy assembled from spec
@@ -72,45 +80,97 @@ func refreshTimeout() time.Duration {
}
// autoRefreshIfStale decides whether to refresh and runs the refresh in
-// one call. It returns nothing: refresh failures become stderr warnings
-// and the command proceeds with the stale cache. The data-source contract
-// is respected: only "auto" triggers the hook; "live" and "local" short-
-// circuit at the first check.
-func autoRefreshIfStale(ctx context.Context, flags *rootFlags, resources []string) {
+// one call. Refresh failures become stderr warnings and the command proceeds
+// with the stale cache. The returned metadata is attached to generated JSON
+// provenance envelopes under meta.freshness.
+func autoRefreshIfStale(ctx context.Context, flags *rootFlags, resources []string) (meta cliutil.FreshnessMeta) {
+ started := time.Now()
+ meta = cliutil.FreshnessMeta{
+ Decision: "skipped",
+ Resources: append([]string(nil), resources...),
+ Source: flags.dataSource,
+ }
+ defer func() {
+ meta.ElapsedMS = time.Since(started).Milliseconds()
+ }()
if flags.dataSource != "auto" {
- return
+ meta.Reason = "data_source_" + flags.dataSource
+ return meta
}
if len(resources) == 0 {
- return
+ meta.Reason = "no_resources"
+ return meta
}
- dbPath := defaultDBPath("{{.Name}}-pp-cli")
- if _, err := os.Stat(dbPath); err != nil {
- // No cache on disk yet; nothing to refresh against. The first
- // `sync` hydrates and subsequent reads benefit from auto-refresh.
- return
+ policy := cachePolicy()
+ if policy.EnvOptOut != "" && os.Getenv(policy.EnvOptOut) == "1" {
+ meta.Decision = "skipped"
+ meta.Reason = "env_opt_out"
+ return meta
}
+ dbPath := defaultDBPath("{{.Name}}-pp-cli")
db, err := store.Open(dbPath)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: auto-refresh skipped (open: %v)\n", err)
- return
+ meta.Decision = "error"
+ meta.Reason = "open_store"
+ meta.Error = err.Error()
+ return meta
}
defer db.Close()
- policy := cachePolicy()
decision, err := cliutil.EnsureFresh(ctx, db.DB(), resources, policy)
+ meta.Decision = decision.String()
if err != nil {
fmt.Fprintf(os.Stderr, "warning: auto-refresh decision failed: %v\n", err)
- return
+ meta.Decision = "error"
+ meta.Reason = "decision_failed"
+ meta.Error = err.Error()
+ return meta
}
if decision == cliutil.DecisionFresh || decision == cliutil.DecisionNoStore {
- return
+ meta.Reason = decision.String()
+ return meta
}
refreshCtx, cancel := context.WithTimeout(ctx, refreshTimeout())
defer cancel()
+ meta.Ran = true
if err := runAutoRefresh(refreshCtx, flags, db, resources); err != nil {
fmt.Fprintf(os.Stderr, "warning: using stale {{.Name}}-pp-cli cache (refresh failed: %v)\n", err)
+ meta.Reason = "refresh_failed"
+ meta.Error = err.Error()
+ return meta
+ }
+ meta.Reason = "refreshed"
+ return meta
+}
+
+// ensureFreshForResources lets hand-authored commands participate in the same
+// freshness hook as generated resource commands. Custom commands should call
+// this before reading from the store, then use wrapWithProvenance or
+// wrapResultsWithFreshness for JSON output if they want meta.freshness.
+func ensureFreshForResources(ctx context.Context, flags *rootFlags, resources ...string) cliutil.FreshnessMeta {
+ meta := autoRefreshIfStale(ctx, flags, resources)
+ flags.freshnessMeta = meta
+ return meta
+}
+
+// ensureFreshForCommand looks up a registered command path in
+// readCommandResources and applies the same freshness hook used by root
+// pre-run. commandPath must match cobra.CommandPath(), including the binary
+// name. It returns skipped metadata for unregistered commands.
+func ensureFreshForCommand(ctx context.Context, flags *rootFlags, commandPath string) cliutil.FreshnessMeta {
+ resources, ok := readCommandResources[commandPath]
+ if !ok {
+ meta := cliutil.FreshnessMeta{
+ Decision: "skipped",
+ Reason: "unregistered_command",
+ Source: flags.dataSource,
+ }
+ flags.freshnessMeta = meta
+ return meta
}
+ return ensureFreshForResources(ctx, flags, resources...)
}
// runAutoRefresh invokes the API-backed refresh path for the stale resources.
diff --git a/internal/generator/templates/cliutil_freshness.go.tmpl b/internal/generator/templates/cliutil_freshness.go.tmpl
index ca3a1c60..2bbe0b2e 100644
--- a/internal/generator/templates/cliutil_freshness.go.tmpl
+++ b/internal/generator/templates/cliutil_freshness.go.tmpl
@@ -51,6 +51,19 @@ func (d Decision) String() string {
}
}
+// FreshnessMeta is the stable agent-facing metadata shape for commands that
+// participate in machine-owned freshness. Generated JSON provenance envelopes
+// attach this under meta.freshness.
+type FreshnessMeta struct {
+ Decision string `json:"decision"` // fresh, stale-api, stale-share, no-store, skipped, or error
+ Ran bool `json:"ran"` // true when the hook attempted a refresh
+ Reason string `json:"reason,omitempty"` // fresh, refreshed, refresh_failed, data_source_live, etc.
+ Resources []string `json:"resources,omitempty"` // resource types covered by the command path
+ ElapsedMS int64 `json:"elapsed_ms,omitempty"` // wall-clock spent in the freshness hook
+ Error string `json:"error,omitempty"` // refresh/decision error, redacted only by the caller if needed
+ Source string `json:"source,omitempty"` // current data-source mode: auto, local, or live
+}
+
// Policy configures a freshness check. StaleAfter is the default threshold
// applied to any resource not listed in PerResource. PerResource is an
// override map (resource_type -> per-type threshold). EnvOptOut, when set
diff --git a/internal/generator/templates/cliutil_freshness_test.go.tmpl b/internal/generator/templates/cliutil_freshness_test.go.tmpl
index 13d65a22..b19a4819 100644
--- a/internal/generator/templates/cliutil_freshness_test.go.tmpl
+++ b/internal/generator/templates/cliutil_freshness_test.go.tmpl
@@ -6,6 +6,7 @@ package cliutil
import (
"context"
"database/sql"
+ "encoding/json"
"path/filepath"
"testing"
"time"
@@ -219,3 +220,27 @@ func TestDecision_StringStableTags(t *testing.T) {
}
}
}
+
+func TestFreshnessMeta_JSONShape(t *testing.T) {
+ meta := FreshnessMeta{
+ Decision: "stale-api",
+ Ran: true,
+ Reason: "refreshed",
+ Resources: []string{"issues"},
+ ElapsedMS: 12,
+ Source: "auto",
+ }
+ data, err := json.Marshal(meta)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ var got map[string]any
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ for _, key := range []string{"decision", "ran", "reason", "resources", "elapsed_ms", "source"} {
+ if _, ok := got[key]; !ok {
+ t.Fatalf("missing json key %q in %s", key, string(data))
+ }
+ }
+}
diff --git a/internal/generator/templates/data_source.go.tmpl b/internal/generator/templates/data_source.go.tmpl
index 74d1ae11..663a41c1 100644
--- a/internal/generator/templates/data_source.go.tmpl
+++ b/internal/generator/templates/data_source.go.tmpl
@@ -68,6 +68,13 @@ func localProvenance(db *store.Store, resourceType, reason string) DataProvenanc
return prov
}
+func attachFreshness(prov DataProvenance, flags *rootFlags) DataProvenance {
+ if flags != nil {
+ prov.Freshness = flags.freshnessMeta
+ }
+ return prov
+}
+
// resolveRead dispatches a GET request to either the live API or local store
// based on the --data-source flag. Returns the response data and provenance metadata.
//
@@ -81,32 +88,32 @@ func localProvenance(db *store.Store, resourceType, reason string) DataProvenanc
func resolveRead(c *client.Client, flags *rootFlags, resourceType string, isList bool, path string, params map[string]string) (json.RawMessage, DataProvenance, error) {
switch flags.dataSource {
case "local":
- return resolveLocal(resourceType, isList, path, params, "user_requested")
+ data, prov, err := resolveLocal(resourceType, isList, path, params, "user_requested")
+ return data, attachFreshness(prov, flags), err
case "live":
data, err := c.Get(path, params)
if err != nil {
return nil, DataProvenance{}, err
}
- writeThroughCache(resourceType, data)
- return data, DataProvenance{Source: "live"}, nil
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
default: // "auto"
data, err := c.Get(path, params)
if err == nil {
writeThroughCache(resourceType, data)
- return data, DataProvenance{Source: "live"}, nil
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
}
if !isNetworkError(err) {
// HTTP 4xx/5xx errors propagate — not a fallback case
return nil, DataProvenance{}, err
}
// Network error — try local fallback
- localData, prov, localErr := resolveLocal(resourceType, isList, path, params, "api_unreachable")
- if localErr != nil {
+ fallbackData, fallbackProv, fallbackErr := resolveLocal(resourceType, isList, path, params, "api_unreachable")
+ if fallbackErr != nil {
return nil, DataProvenance{}, fmt.Errorf("API unreachable and no local data. Run '{{.Name}}-pp-cli sync' to enable offline access.\n\nOriginal error: %w", err)
}
- return localData, prov, nil
+ return fallbackData, attachFreshness(fallbackProv, flags), nil
}
}
@@ -115,30 +122,30 @@ func resolveRead(c *client.Client, flags *rootFlags, resourceType string, isList
func resolvePaginatedRead(c *client.Client, flags *rootFlags, resourceType string, path string, params map[string]string, fetchAll bool, cursorParam, nextCursorPath, hasMoreField string) (json.RawMessage, DataProvenance, error) {
switch flags.dataSource {
case "local":
- return resolveLocal(resourceType, true, path, params, "user_requested")
+ data, prov, err := resolveLocal(resourceType, true, path, params, "user_requested")
+ return data, attachFreshness(prov, flags), err
case "live":
data, err := paginatedGet(c, path, params, fetchAll, cursorParam, nextCursorPath, hasMoreField)
if err != nil {
return nil, DataProvenance{}, err
}
- writeThroughCache(resourceType, data)
- return data, DataProvenance{Source: "live"}, nil
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
default: // "auto"
data, err := paginatedGet(c, path, params, fetchAll, cursorParam, nextCursorPath, hasMoreField)
if err == nil {
writeThroughCache(resourceType, data)
- return data, DataProvenance{Source: "live"}, nil
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
}
if !isNetworkError(err) {
return nil, DataProvenance{}, err
}
- localData, prov, localErr := resolveLocal(resourceType, true, path, params, "api_unreachable")
- if localErr != nil {
+ fallbackData, fallbackProv, fallbackErr := resolveLocal(resourceType, true, path, params, "api_unreachable")
+ if fallbackErr != nil {
return nil, DataProvenance{}, fmt.Errorf("API unreachable and no local data. Run '{{.Name}}-pp-cli sync' to enable offline access.\n\nOriginal error: %w", err)
}
- return localData, prov, nil
+ return fallbackData, attachFreshness(fallbackProv, flags), nil
}
}
diff --git a/internal/generator/templates/helpers.go.tmpl b/internal/generator/templates/helpers.go.tmpl
index fd33b3c6..ccb1e88f 100644
--- a/internal/generator/templates/helpers.go.tmpl
+++ b/internal/generator/templates/helpers.go.tmpl
@@ -1104,6 +1104,7 @@ type DataProvenance struct {
SyncedAt *time.Time `json:"synced_at,omitempty"` // when local data was last synced
Reason string `json:"reason,omitempty"` // why local was used: "user_requested", "api_unreachable", "no_search_endpoint"
ResourceType string `json:"resource_type,omitempty"` // which resource type was queried
+ Freshness any `json:"freshness,omitempty"` // optional machine-owned freshness metadata for covered command paths
}
// printProvenance writes a one-line provenance message to stderr for TTY users.
@@ -1151,6 +1152,9 @@ func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMess
if prov.ResourceType != "" {
meta["resource_type"] = prov.ResourceType
}
+ if prov.Freshness != nil {
+ meta["freshness"] = prov.Freshness
+ }
envelope := map[string]any{
"results": json.RawMessage(data),
"meta": meta,
@@ -1158,6 +1162,18 @@ func wrapWithProvenance(data json.RawMessage, prov DataProvenance) (json.RawMess
return json.Marshal(envelope)
}
+// wrapResultsWithFreshness gives hand-authored commands a small opt-in helper
+// for the generated provenance envelope without forcing arbitrary custom JSON
+// outputs to change shape.
+func wrapResultsWithFreshness(data json.RawMessage, flags *rootFlags) (json.RawMessage, error) {
+ prov := DataProvenance{}
+ if flags != nil {
+ prov.Source = flags.dataSource
+ prov.Freshness = flags.freshnessMeta
+ }
+ return wrapWithProvenance(data, prov)
+}
+
// defaultDBPath returns the canonical path for the local SQLite database.
func defaultDBPath(name string) string {
home, _ := os.UserHomeDir()
diff --git a/internal/generator/templates/readme.md.tmpl b/internal/generator/templates/readme.md.tmpl
index 3d01448a..abcdda4b 100644
--- a/internal/generator/templates/readme.md.tmpl
+++ b/internal/generator/templates/readme.md.tmpl
@@ -235,6 +235,23 @@ This CLI is designed for AI agent consumption:
- **Agent-safe by default** - no colors or formatting unless `--human-friendly` is set
Exit codes: `0` success, `2` usage error, `3` not found{{if .HasAuth}}, `4` auth error{{end}}, `5` API error, `7` rate limited, `10` config error.
+{{- if and .HasDataLayer .Cache.Enabled}}
+
+## Freshness
+
+This CLI owns bounded freshness for registered store-backed read command paths. In `--data-source auto` mode, covered commands check the local SQLite store before serving results; stale or missing resources trigger a bounded refresh, and refresh failures fall back to the existing local data with a warning. `--data-source local` never refreshes, and `--data-source live` reads the API without mutating the local store.
+
+Set `{{if .Cache.EnvOptOut}}{{.Cache.EnvOptOut}}{{else}}{{envName .Name}}_NO_AUTO_REFRESH{{end}}=1` to disable the pre-read freshness hook while preserving the selected data source.
+{{- if .FreshnessCommands}}
+
+Covered command paths:
+{{- range .FreshnessCommands}}
+- `{{.}}`
+{{- end}}
+{{- end}}
+
+JSON outputs that use the generated provenance envelope include freshness metadata at `meta.freshness`. This metadata describes the freshness decision for the covered command path; it does not claim full historical backfill or API-specific enrichment.
+{{- end}}
## Use as MCP Server
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index dcc2dd98..286b1914 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -38,6 +38,7 @@ type rootFlags struct {
timeout time.Duration
rateLimit float64
dataSource string
+ freshnessMeta any
// deliverBuf captures command output when --deliver is set to a
// non-stdout sink. Flushed to the sink after Execute returns.
@@ -177,7 +178,7 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
// runs a bounded API refresh. Failures become stderr warnings;
// the command proceeds with the stale cache either way.
if resources, isRead := readCommandResources[cmd.CommandPath()]; isRead {
- autoRefreshIfStale(cmd.Context(), &flags, resources)
+ flags.freshnessMeta = autoRefreshIfStale(cmd.Context(), &flags, resources)
}
{{- end}}
return nil
diff --git a/internal/generator/templates/skill.md.tmpl b/internal/generator/templates/skill.md.tmpl
index 6c62c8c5..36604413 100644
--- a/internal/generator/templates/skill.md.tmpl
+++ b/internal/generator/templates/skill.md.tmpl
@@ -109,6 +109,21 @@ This CLI was generated with browser-observed traffic context.
- `{{$.Name}}-pp-cli {{.Name}}{{if .Args}} {{.Args}}{{end}}` — {{oneline .Description}}
{{- end}}
{{end}}
+{{- if and .HasDataLayer .Cache.Enabled}}
+
+## Freshness Contract
+
+This printed CLI owns bounded freshness only for registered store-backed read command paths. In `--data-source auto` mode, those paths check `sync_state` and may run a bounded refresh before reading local data. `--data-source local` never refreshes. `--data-source live` reads the API and does not mutate the local store. Set `{{if .Cache.EnvOptOut}}{{.Cache.EnvOptOut}}{{else}}{{envName .Name}}_NO_AUTO_REFRESH{{end}}=1` to skip the freshness hook without changing source selection.
+{{- if .FreshnessCommands}}
+
+Covered paths:
+{{range .FreshnessCommands}}
+- `{{.}}`
+{{- end}}
+{{- end}}
+
+When JSON output uses the generated provenance envelope, freshness metadata appears at `meta.freshness`. Treat it as current-cache freshness for the covered command path, not a guarantee of complete historical backfill or API-specific enrichment.
+{{- end}}
### Finding the right command
diff --git a/internal/generator/validate.go b/internal/generator/validate.go
index 03798b39..5c30cd12 100644
--- a/internal/generator/validate.go
+++ b/internal/generator/validate.go
@@ -146,6 +146,12 @@ func runCommand(dir string, timeout time.Duration, name string, args ...string)
}
func goBuildCacheDir(dir string) (string, error) {
+ if cacheDir := os.Getenv("GOCACHE"); cacheDir != "" {
+ if err := os.MkdirAll(cacheDir, 0o755); err != nil {
+ return "", fmt.Errorf("creating GOCACHE dir: %w", err)
+ }
+ return cacheDir, nil
+ }
homeDir, err := os.UserHomeDir()
if err != nil {
absDir, absErr := filepath.Abs(dir)
diff --git a/internal/generator/validate_test.go b/internal/generator/validate_test.go
index 5f9498fb..3d44c127 100644
--- a/internal/generator/validate_test.go
+++ b/internal/generator/validate_test.go
@@ -10,6 +10,8 @@ import (
)
func TestGoBuildCacheDirIsShared(t *testing.T) {
+ t.Setenv("GOCACHE", "")
+
// Two different project directories should get the same cache dir.
// This is critical for CI performance — shared cache avoids each
// parallel test recompiling the Go standard library from scratch.
@@ -23,6 +25,8 @@ func TestGoBuildCacheDirIsShared(t *testing.T) {
}
func TestGoBuildCacheDirPath(t *testing.T) {
+ t.Setenv("GOCACHE", "")
+
dir, err := goBuildCacheDir("/tmp/any-project")
require.NoError(t, err)
@@ -32,3 +36,14 @@ func TestGoBuildCacheDirPath(t *testing.T) {
expected := filepath.Join(home, ".cache", "printing-press", "go-build")
assert.Equal(t, expected, dir)
}
+
+func TestGoBuildCacheDirHonorsExplicitGOCACHE(t *testing.T) {
+ cacheDir := filepath.Join(t.TempDir(), "go-build")
+ t.Setenv("GOCACHE", cacheDir)
+
+ dir, err := goBuildCacheDir("/tmp/any-project")
+ require.NoError(t, err)
+
+ assert.Equal(t, cacheDir, dir)
+ assert.DirExists(t, cacheDir)
+}
diff --git a/internal/pipeline/runtime.go b/internal/pipeline/runtime.go
index 98e28279..10765c40 100644
--- a/internal/pipeline/runtime.go
+++ b/internal/pipeline/runtime.go
@@ -39,6 +39,7 @@ type VerifyReport struct {
PassRate float64 `json:"pass_rate"`
DataPipeline bool `json:"data_pipeline"`
DataPipelineDetail string `json:"data_pipeline_detail,omitempty"` // PASS, WARN, SKIP, FAIL with context
+ Freshness FreshnessResult `json:"freshness,omitempty"`
BrowserSessionRequired bool `json:"browser_session_required,omitempty"`
BrowserSessionProof string `json:"browser_session_proof,omitempty"`
BrowserSessionDetail string `json:"browser_session_detail,omitempty"`
@@ -58,6 +59,16 @@ type CommandResult struct {
Error string `json:"error,omitempty"`
}
+type FreshnessResult struct {
+ Enabled bool `json:"enabled"`
+ RegisteredPaths int `json:"registered_paths,omitempty"`
+ Metadata bool `json:"metadata,omitempty"`
+ LiveBypass bool `json:"live_bypass,omitempty"`
+ HelperSurface bool `json:"helper_surface,omitempty"`
+ Verdict string `json:"verdict,omitempty"` // PASS, WARN, SKIP, FAIL
+ Detail string `json:"detail,omitempty"`
+}
+
// RunVerify executes the runtime verification pipeline.
func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
if cfg.NoSpec {
@@ -191,6 +202,7 @@ func RunVerify(cfg VerifyConfig) (*VerifyReport, error) {
// 8. Data pipeline test
report.DataPipeline, report.DataPipelineDetail = runDataPipelineTest(binaryPath, report.Mode, buildEnv)
+ report.Freshness = runFreshnessContractTest(cfg.Dir)
if spec != nil && spec.Auth.RequiresBrowserSession {
report.BrowserSessionRequired = true
@@ -280,6 +292,7 @@ func runStructuralVerify(cfg VerifyConfig) (*VerifyReport, error) {
} else {
report.DataPipelineDetail = "FAIL (version command)"
}
+ report.Freshness = runFreshnessContractTest(cfg.Dir)
// 5. Aggregate
for _, r := range report.Results {
@@ -1015,6 +1028,56 @@ func parseCountOutput(out []byte) int {
return 0
}
+func runFreshnessContractTest(dir string) FreshnessResult {
+ autoRefresh := readRuntimeFile(filepath.Join(dir, "internal", "cli", "auto_refresh.go"))
+ freshness := readRuntimeFile(filepath.Join(dir, "internal", "cliutil", "freshness.go"))
+ helpers := readRuntimeFile(filepath.Join(dir, "internal", "cli", "helpers.go"))
+ dataSource := readRuntimeFile(filepath.Join(dir, "internal", "cli", "data_source.go"))
+ if autoRefresh == "" && freshness == "" {
+ return FreshnessResult{Verdict: "SKIP", Detail: "cache freshness helper not emitted"}
+ }
+
+ liveBypass := strings.Contains(dataSource, `case "live":`) &&
+ !strings.Contains(dataSource, "writeThroughCache(resourceType, data)\n\t\treturn data, attachFreshness(DataProvenance{Source: \"live\"}, flags), nil")
+ result := FreshnessResult{
+ Enabled: true,
+ RegisteredPaths: strings.Count(autoRefresh, "-pp-cli "),
+ Metadata: strings.Contains(freshness, "type FreshnessMeta struct") && strings.Contains(helpers, `meta["freshness"]`),
+ LiveBypass: liveBypass,
+ HelperSurface: strings.Contains(autoRefresh, "func ensureFreshForResources(") && strings.Contains(autoRefresh, "func ensureFreshForCommand("),
+ }
+
+ var missing []string
+ if result.RegisteredPaths == 0 {
+ missing = append(missing, "registered command paths")
+ }
+ if !result.Metadata {
+ missing = append(missing, "meta.freshness metadata")
+ }
+ if !result.LiveBypass {
+ missing = append(missing, "live data-source bypass")
+ }
+ if !result.HelperSurface {
+ missing = append(missing, "custom command helper surface")
+ }
+ if len(missing) > 0 {
+ result.Verdict = "WARN"
+ result.Detail = "missing " + strings.Join(missing, ", ")
+ return result
+ }
+ result.Verdict = "PASS"
+ result.Detail = "freshness registry, metadata, live bypass, and custom helper surface present"
+ return result
+}
+
+func readRuntimeFile(path string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return string(data)
+}
+
// startMockServer creates an httptest.Server from the OpenAPI spec.
func startMockServer(spec *openAPISpec) (*httptest.Server, string) {
mux := http.NewServeMux()
diff --git a/internal/pipeline/runtime_test.go b/internal/pipeline/runtime_test.go
index b319ece5..52b94af4 100644
--- a/internal/pipeline/runtime_test.go
+++ b/internal/pipeline/runtime_test.go
@@ -62,6 +62,46 @@ func main() {
assert.FileExists(t, existingBinary)
}
+func TestRunFreshnessContractTestPassesGeneratedSurface(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cli"), 0o755))
+ require.NoError(t, os.MkdirAll(filepath.Join(dir, "internal", "cliutil"), 0o755))
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "auto_refresh.go"), `package cli
+var readCommandResources = map[string][]string{"demo-pp-cli items list": {"items"}}
+func ensureFreshForResources() {}
+func ensureFreshForCommand() {}
+`)
+ writeTestFile(t, filepath.Join(dir, "internal", "cliutil", "freshness.go"), `package cliutil
+type FreshnessMeta struct {}
+`)
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "helpers.go"), `package cli
+func wrap() { meta["freshness"] = nil }
+`)
+ writeTestFile(t, filepath.Join(dir, "internal", "cli", "data_source.go"), `package cli
+func resolveRead() {
+ switch flags.dataSource {
+ case "live":
+ data, err := c.Get(path, params)
+ if err != nil { return }
+ return data, attachFreshness(DataProvenance{Source: "live"}, flags), nil
+ }
+}
+`)
+
+ result := runFreshnessContractTest(dir)
+ assert.Equal(t, "PASS", result.Verdict)
+ assert.True(t, result.Metadata)
+ assert.True(t, result.LiveBypass)
+ assert.True(t, result.HelperSurface)
+ assert.Greater(t, result.RegisteredPaths, 0)
+}
+
+func TestRunFreshnessContractTestSkipsWhenNotEmitted(t *testing.T) {
+ result := runFreshnessContractTest(t.TempDir())
+ assert.Equal(t, "SKIP", result.Verdict)
+ assert.False(t, result.Enabled)
+}
+
func TestRunBrowserSessionProofTestRequiresValidDoctorProof(t *testing.T) {
binary := buildDoctorJSONBinary(t, `{"browser_session_proof":"missing or stale","browser_session_proof_detail":"proof not found"}`)
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 945a3975..024d56f5 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -213,6 +213,14 @@ type CacheConfig struct {
RefreshTimeout string `yaml:"refresh_timeout,omitempty" json:"refresh_timeout,omitempty"` // max wall-clock the pre-run refresh may block the command (e.g., "30s"). On timeout the command serves stale data with a stderr warning. Blank means runtime default (30s).
EnvOptOut string `yaml:"env_opt_out,omitempty" json:"env_opt_out,omitempty"` // env var name that disables auto-refresh when set to "1" (e.g., LINEAR_NO_AUTO_REFRESH). Blank lets the template derive {{upper name}}_NO_AUTO_REFRESH.
Resources map[string]string `yaml:"resources,omitempty" json:"resources,omitempty"` // per-resource override of stale_after (e.g., quotes: "5m", channels: "24h"). Resources not listed inherit StaleAfter.
+ Commands []CacheCommand `yaml:"commands,omitempty" json:"commands,omitempty"` // optional custom command-path coverage for hand-authored store-backed reads. Generated resource commands are covered automatically.
+}
+
+// CacheCommand declares that a hand-authored command path reads one or more
+// syncable resources and should participate in the generated freshness hook.
+type CacheCommand struct {
+ Name string `yaml:"name" json:"name"` // lowercase cobra command path, without the binary name (e.g., "today" or "insights stale")
+ Resources []string `yaml:"resources" json:"resources"` // resource names to refresh before serving the command
}
// ShareConfig gates the git-backed snapshot share surface emitted into a
@@ -575,7 +583,7 @@ func (s *APISpec) Validate() error {
if err := validateExtraCommands(s.ExtraCommands); err != nil {
return err
}
- if err := validateCacheShare(s.Cache, s.Share); err != nil {
+ if err := validateCacheShare(s.Cache, s.Share, s.Resources); err != nil {
return err
}
if err := validateMCP(s.MCP, s.Resources); err != nil {
@@ -690,7 +698,7 @@ var shareTableNameRe = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
// malformed values so typos surface at spec load, not at end-user runtime.
var durationLikeRe = regexp.MustCompile(`^\d+(\.\d+)?(ns|us|µs|ms|s|m|h)(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))*$`)
-func validateCacheShare(cache CacheConfig, share ShareConfig) error {
+func validateCacheShare(cache CacheConfig, share ShareConfig, resources map[string]Resource) error {
if cache.StaleAfter != "" && !durationLikeRe.MatchString(cache.StaleAfter) {
return fmt.Errorf("cache.stale_after %q is not a valid Go duration", cache.StaleAfter)
}
@@ -705,6 +713,38 @@ func validateCacheShare(cache CacheConfig, share ShareConfig) error {
return fmt.Errorf("cache.resources[%s] = %q is not a valid Go duration", resource, dur)
}
}
+ if !cache.Enabled && len(cache.Commands) > 0 {
+ return fmt.Errorf("cache.commands is set but cache.enabled is false; either enable cache or remove")
+ }
+ seenCommands := make(map[string]struct{}, len(cache.Commands))
+ for i, command := range cache.Commands {
+ if command.Name == "" {
+ return fmt.Errorf("cache.commands[%d]: name is required", i)
+ }
+ if !extraCommandNameRe.MatchString(command.Name) {
+ return fmt.Errorf("cache.commands[%d]: name %q must be lowercase command path (one to three segments separated by single spaces, lowercase letters, digits, and hyphens)", i, command.Name)
+ }
+ if _, dup := seenCommands[command.Name]; dup {
+ return fmt.Errorf("cache.commands[%d]: name %q appears more than once", i, command.Name)
+ }
+ seenCommands[command.Name] = struct{}{}
+ if len(command.Resources) == 0 {
+ return fmt.Errorf("cache.commands[%d] (%s): resources must not be empty", i, command.Name)
+ }
+ seenResources := make(map[string]struct{}, len(command.Resources))
+ for j, resource := range command.Resources {
+ if resource == "" {
+ return fmt.Errorf("cache.commands[%d].resources[%d]: resource name must not be empty", i, j)
+ }
+ if _, ok := resources[resource]; !ok {
+ return fmt.Errorf("cache.commands[%d].resources[%d]: resource %q is not declared in resources", i, j, resource)
+ }
+ if _, dup := seenResources[resource]; dup {
+ return fmt.Errorf("cache.commands[%d].resources[%d]: resource %q appears more than once", i, j, resource)
+ }
+ seenResources[resource] = struct{}{}
+ }
+ }
if !share.Enabled {
if len(share.SnapshotTables) > 0 {
diff --git a/internal/spec/spec_test.go b/internal/spec/spec_test.go
index 986dff65..7edef950 100644
--- a/internal/spec/spec_test.go
+++ b/internal/spec/spec_test.go
@@ -788,6 +788,9 @@ cache:
env_opt_out: DEMO_NO_AUTO_REFRESH
resources:
items: 5m
+ commands:
+ - name: dashboard
+ resources: [items]
share:
enabled: true
snapshot_tables:
@@ -803,6 +806,9 @@ share:
assert.Equal(t, "30s", s.Cache.RefreshTimeout)
assert.Equal(t, "DEMO_NO_AUTO_REFRESH", s.Cache.EnvOptOut)
assert.Equal(t, "5m", s.Cache.Resources["items"])
+ require.Len(t, s.Cache.Commands, 1)
+ assert.Equal(t, "dashboard", s.Cache.Commands[0].Name)
+ assert.Equal(t, []string{"items"}, s.Cache.Commands[0].Resources)
assert.True(t, s.Share.Enabled)
assert.Equal(t, []string{"items", "sync_state"}, s.Share.SnapshotTables)
assert.Equal(t, "git@github.com:acme/demo-snapshots.git", s.Share.DefaultRepo)
@@ -903,6 +909,36 @@ func TestCacheShareValidation(t *testing.T) {
cache: CacheConfig{Enabled: true, Resources: map[string]string{"items": "eh"}},
wantErr: "not a valid Go duration",
},
+ {
+ name: "cache command uppercase rejected",
+ cache: CacheConfig{Enabled: true, Commands: []CacheCommand{{Name: "Today", Resources: []string{"items"}}}},
+ wantErr: "lowercase command path",
+ },
+ {
+ name: "cache command requires enabled cache",
+ cache: CacheConfig{Commands: []CacheCommand{{Name: "today", Resources: []string{"items"}}}},
+ wantErr: "cache.enabled is false",
+ },
+ {
+ name: "cache command duplicate rejected",
+ cache: CacheConfig{Enabled: true, Commands: []CacheCommand{{Name: "today", Resources: []string{"items"}}, {Name: "today", Resources: []string{"items"}}}},
+ wantErr: "appears more than once",
+ },
+ {
+ name: "cache command resources required",
+ cache: CacheConfig{Enabled: true, Commands: []CacheCommand{{Name: "today"}}},
+ wantErr: "resources must not be empty",
+ },
+ {
+ name: "cache command unknown resource rejected",
+ cache: CacheConfig{Enabled: true, Commands: []CacheCommand{{Name: "today", Resources: []string{"launches"}}}},
+ wantErr: "is not declared in resources",
+ },
+ {
+ name: "cache command duplicate resource rejected",
+ cache: CacheConfig{Enabled: true, Commands: []CacheCommand{{Name: "today", Resources: []string{"items", "items"}}}},
+ wantErr: "appears more than once",
+ },
}
for _, tt := range tests {
@@ -929,6 +965,10 @@ func TestCacheShareAcceptsValidShapes(t *testing.T) {
name: "cache with per-resource overrides",
cache: CacheConfig{Enabled: true, StaleAfter: "6h", Resources: map[string]string{"items": "5m", "teams": "24h"}},
},
+ {
+ name: "cache with custom command coverage",
+ cache: CacheConfig{Enabled: true, Commands: []CacheCommand{{Name: "dashboard", Resources: []string{"items"}}}},
+ },
{
name: "share only, no cache",
share: ShareConfig{Enabled: true, SnapshotTables: []string{"items", "teams", "sync_state"}},
← 92c5b2f4 chore(main): release 2.2.0 (#184)
·
back to Cli Printing Press
·
chore(main): release 2.3.0 (#250) 5353fb38 →