Skip to content

feat(state): identity-only state.json; live-API attribute reads at plan time (RFC 0001)#15

Merged
caballeto merged 1 commit into
mainfrom
feat/state-v3-identity-only
Apr 22, 2026
Merged

feat(state): identity-only state.json; live-API attribute reads at plan time (RFC 0001)#15
caballeto merged 1 commit into
mainfrom
feat/state-v3-identity-only

Conversation

@caballeto
Copy link
Copy Markdown
Member

@caballeto caballeto commented Apr 22, 2026

Summary

Implements RFC 0001. The CLI now diffs YAML against the live API at plan time instead of against a stale state.json snapshot. state.json keeps only identity (apiId + child apiIds).

This fixes a class of bugs we keep hitting (most recently defaultOpen flapping between mini and prod when the same operator deployed to both). It also subsumes Issue A (pullStatusPageChildren underfilling): the differ never reads child attributes from state, so a partial state pull cannot mislead the next plan.

What changed

  • state.json schema bumped to v3 (identity-only). v1→v3 and v2→v3 reader-side migrations strip attributes silently on load — no user migration step.
  • differ.diff() is now async and accepts a ChildSnapshotMap. New prefetchChildSnapshots(config, refs, client) runs all child fetches concurrently up-front.
  • statusPageHandler declares fetchChildSnapshots (live-API read of groups + components). hasChildChanges diffs YAML against the live snapshot, not state.
  • applier, child-reconciler, state pull, and import no longer write attributes. upsertStateEntry lost the parameter.
  • buildStateV2 kept as a deprecated alias of buildState that silently drops attributes — preserves the public name until the next major.

Behavioral impact

Scenario Before After
Same YAML deployed to two envs with different DB defaults Phantom diff on env #2 (state-vs-yaml) Detects real env-#2 drift correctly
Out-of-band edit to a status-page child via dashboard Plan misses it (state still says original) Plan flags it (live API is the baseline)
Fresh devhelm state pull after manual import "Spurious update" because pull underfilled Clean plan (no attributes consulted)
state.json from v0.4.x n/a Loaded transparently; stripped to v3 on first write

Test plan

  • npm run typecheck — clean
  • npm run lint — clean
  • npm test870/870 passing
    • State-migration tests (v1→v3, v2→v3) added in test/yaml/state.test.ts.
    • differ.test.ts "FIXED:" cases now exercise the live-snapshot path via injected ChildSnapshotMap.
    • partial-failure-convergence.test.ts (j) asserts identity-only child entries; convergence still holds because the next plan re-reads the API.
  • Manual smoke: run devhelm plan against mini after pulling latest state, confirm no phantom diff on the existing defaultOpen case.
  • Manual smoke: edit a status-page component in the dashboard, run devhelm plan, confirm drift surfaces.

Pairs with

devhelmhq/mono#266 — dashboard fix to show the real public URL (devhelm.io/sp/<slug>) for status pages. Independent change but shipped together because both surfaced from the same audit.

Out of scope (next PR)

<slug>.devhelm.dev first-party domain — needs domain registration, Cloudflare config, dashboard + web wiring. Tracked separately.

…ads at plan time (RFC 0001)

Issue: `devhelm plan` reported phantom diffs and missed real drift on
status-page children because the differ compared YAML against stale
attributes stored in `state.json` instead of the live API. Most
recently surfaced as `defaultOpen` flapping between mini + prod.

Architecture (RFC 0001):
- state.json bumped to v3; child entries store `apiId` only. v1→v3
  and v2→v3 reader-side migrations strip `attributes` silently on
  load — no user-facing migration step.
- Differ now async; `plan`/`deploy` call a new
  `prefetchChildSnapshots(config, refs, client)` that fetches all
  status-page groups + components from the API up-front. Tests inject
  the same `ChildSnapshotMap` instead of mocking HTTP.
- `statusPageHandler` declares `fetchChildSnapshots` and a
  `hasChildChanges` that diffs YAML against the live snapshot, not
  state. Subsumes the Issue A fix: `pullStatusPageChildren` no longer
  needs to mirror every server-side default.
- `applier`, `child-reconciler`, `state pull`, and `import` no longer
  write `attributes`. `upsertStateEntry` lost the parameter.
- `buildStateV2` kept as a deprecated alias of `buildState` that
  silently drops attributes — preserves the public name until the
  next major.

Tests: 870/870 green. Highlights:
- `state.test.ts` rewritten end-to-end for v3 + migration coverage.
- `differ.test.ts` "FIXED:" cases now exercise the live-snapshot path
  via injected `ChildSnapshotMap`.
- `partial-failure-convergence.test.ts` (j) updated to assert
  identity-only child entries; convergence still holds because the
  next plan re-reads the API.

Docs: `docs/rfcs/0001-state-as-identity-only.md` marked Accepted with
a shipped-in-0.5.0 implementation note.

Made-with: Cursor
@caballeto caballeto merged commit 80f7f9b into main Apr 22, 2026
3 checks passed
@caballeto caballeto deleted the feat/state-v3-identity-only branch April 22, 2026 17:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant