Skip to content

ci(migrations): downgrade-and-revert + restore-drill lanes + upgrade-lane migration assertions#692

Open
tps-flint wants to merge 1 commit into
mainfrom
cp-migration-ci-lanes
Open

ci(migrations): downgrade-and-revert + restore-drill lanes + upgrade-lane migration assertions#692
tps-flint wants to merge 1 commit into
mainfrom
cp-migration-ci-lanes

Conversation

@tps-flint

Copy link
Copy Markdown
Contributor

What this proves (maps to the ratified safety-doc invariants)

Enforcement arm of ~/ops/FLAIR-CI-FOUNDATION.md §2 and ~/ops/FLAIR-MIGRATION-SAFETY.md for the zero-touch boot-keyed auto-migration runner (#690). References ~/ops/FLAIR-ZERO-TOUCH-UPGRADE.md's K&S verdict throughout.

New: .github/workflows/migration-ci-lanes.yml

  • downgrade-and-revert — Kern: "the proof of invariant I". Installs the latest published release → seeds a 140-row corpus via the real CLI write path as the reserved synthetic-migration test agent → swaps in the PR build with FLAIR_ENABLE_TEST_MIGRATIONS=1 + a widened batch delay → catches the synthetic migration genuinely mid-flight via a bounded two-phase poll (mirrors migrations-resume-after-kill.test.ts's proven pattern) → kills Harper → reinstalls the previously-published release against the SAME, now partially-migrated store → asserts it boots and serves the corpus byte-identically (raw ops-API read + a real flair memory search round-trip). Non-negotiable per migration PR per Kern's verdict.

  • snapshot-restore-drill — invariant III: "a backup that has never been restored is a prayer." Seeds → lets the auto-migration run to completion → deliberately corrupts the migration-touched rows via a raw ops-API partial update → restores via the shipped flair snapshot create/flair snapshot restore mechanism → verifies byte-identical integrity and matching counts.

    Design-fit finding for K&S (not a runtime bug, no runtime touched): the auto-migration runner's own internal pre-flight snapshot (resources/migrations/snapshot.ts) is deliberately risk-class-scoped — metadata-only for embedding-stamp (derived-only), schema+metadata for the synthetic migration (schema-additive). Neither writes row content, so there's nothing there to restore FROM for a content-corruption drill; it's a logical in-process manifest, not a byte-exact snapshot, by design (stopping Harper to snapshot inside its own live-serving process would undo the perf(bootstrap): bounded queries replace org-wide memory scan #687 boot win). This lane therefore drills the invariant III "floor beneath everything" mechanism instead — flair snapshot create/restore, the shipped byte-exact mechanism actually capable of content recovery — while separately asserting the migration's own internal snapshot fired (a complementary, cheaper check). Flagging in case FLAIR-MIGRATION-SAFETY.md's "rides the existing flair snapshot machinery" language should be reconciled with the as-implemented architecture.

Extended: upgrade-smoke in test.yml

Seeds stub-stamped rows (raw ops-API bulk insert, same pattern migrations-synthetic-e2e.test.ts uses) as the reserved synthetic-migration test agent before the swap, enables FLAIR_ENABLE_TEST_MIGRATIONS=1 on the HEAD boot, and asserts: (a) the auto-migration ran to completion, (b) its content-hash envelope hashEnvelopeMatch === true (read from the ledger OrgEvent), (c) recall parity — the migration-touched ground truth is still retrievable by flair memory search post-migration. Placed before the existing manual flair reembed step so the two writers never race the same rows.

scripts/ci/migration-lane-lib.sh

Shared bounded-retry primitives (bash, sourced by all three lanes): wait_for_http_ok/wait_for_http_down, wait_for_flair_status_running, wait_for_migration_state, wait_for_migration_mid_flight (two-phase), check_ledger_hash_envelope, fetch_agent_memories_json, dump_migration_diagnostics. Every post-boot/post-restart check in all three lanes goes through one of these — never a single-shot probe.

flair#691 fix (included, small + contained per the dispatch)

pack-smoke's post-restart verify was a single-shot flair status check immediately after flair restart returned — races boot on a slow runner. Replaced with wait_for_flair_status_running (the same bounded-retry primitive the new lanes use). Same fix applied to upgrade-smoke's post-start check while touching that job for the migration assertions above.

Sherlock §4 compliance

  • pull_request-trigger only on the new workflow (never pull_request_target with PR-code checkout) — matches every existing lane's pattern.
  • Zero secrets exposed to either new job (public npm installs + default GITHUB_TOKEN only — no NPM_TOKEN, no deploy credentials).
  • Every third-party action pinned to a full commit SHA (actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2, looked up live via the GitHub API rather than guessed; all other actions reuse SHAs already pinned elsewhere in this repo).
  • No ${{ }} interpolation of PR-controlled metadata (title/body/branch) into any run: block — the only ${{ }} uses are step outputs this workflow itself produces and github.workspace.
  • No new cache keys added (no actions/cache introduced by this PR) — N/A for the ref-scoping requirement.

Local validation performed

  • actionlint (locally installed via Homebrew) against both touched workflow files: clean except two pre-existing SC2086 info-level notes verified present identically on origin/main (unrelated to this diff — confirmed via a direct actionlint run against origin/main's copy).
  • Every run: block in both files (63 total) extracted programmatically and individually passed bash -n.
  • scripts/ci/migration-lane-lib.sh passes bash -n.
  • Both workflow files parse cleanly via js-yaml (repo dependency).
  • Live validation against a real ephemeral Harper (this worktree's own build, on the local dev host): confirmed CLI-seeded Memory rows get a real, synchronous embeddingModel stamp at write time (so embedding-stamp — always-on, runs ahead of the synthetic migration in registry order — doesn't contend with it across a baseline→PR-build swap, without needing the FLAIR_EMBEDDING_MODEL pinning trick migrations-resume-after-kill.test.ts needs for its raw-ops-API seeding path); ran the synthetic migration to completion and read back hashEnvelopeMatch: true from the ledger via check_ledger_hash_envelope; confirmed the internal snapshot directory naming/shape; confirmed the raw ops-API update operation does a genuine partial-field merge (validates the corruption step's semantics); ran a full flair snapshot create → corrupt → flair snapshot restore --yes round trip and verified byte-identical content recovery; unit-tested the mid-flight JSON-extraction logic against a synthetic HealthDetail payload.
  • Full existing unit suite: 2161 pass / 0 fail (no runtime files touched by this PR).

Deviations

  • Used flair snapshot create/restore (not the migration runner's own internal snapshot) as the restore mechanism for the snapshot-restore-drill lane — see the design-fit finding above.
  • Row counts/delays for downgrade-and-revert (140 rows, 2500ms batch delay) reuse migrations-resume-after-kill.test.ts's exact proven constants rather than inventing new ones, to avoid re-deriving a timing tune that's already validated in this codebase.

🤖 Generated with Claude Code

…ane migration assertions (#504/ops-1l18 enforcement)

Enforcement arm of ~/ops/FLAIR-CI-FOUNDATION.md §2 / FLAIR-MIGRATION-SAFETY.md
for the zero-touch boot-keyed auto-migration runner (PR #690):

- New .github/workflows/migration-ci-lanes.yml:
  - downgrade-and-revert: install latest published release, seed a corpus
    via the CLI, swap in the PR build with the synthetic-migration gate on
    and the batch delay widened, catch it genuinely mid-flight (bounded
    two-phase poll), kill Harper, reinstall the previously-published
    release, and assert it boots and serves the corpus correctly against
    the partially-migrated store (Kern's "proof of invariant I").
  - snapshot-restore-drill: seed, let the auto-migration complete, take the
    shipped physical `flair snapshot create` restore point, corrupt the
    migration-touched rows via a raw ops-API partial update, restore via
    `flair snapshot restore`, verify byte-identical integrity + counts.
    (The migration runner's own internal pre-flight snapshot is deliberately
    risk-scoped metadata/schema-only for the two migrations registered
    today and cannot itself restore content — this lane asserts that
    internal snapshot fired as a complementary check, and documents the
    reasoning for K&S; not a runtime change.)

- Extends the existing `upgrade-smoke` job in test.yml: seeds stub-stamped
  rows pre-swap, enables the synthetic-migration gate on the HEAD boot, and
  asserts the auto-migration ran to completion, its content-hash envelope
  matched, and the migration-touched ground truth is still retrievable by
  search (recall parity).

- scripts/ci/migration-lane-lib.sh: shared bounded-retry primitives (never
  single-shot) for post-boot/post-restart verification, migration-state
  polling (two-phase mid-flight catch), ledger envelope reads, and
  self-diagnosing failure dumps (HealthDetail + migration state file +
  Harper's own hdb.log tail).

- flair#691 fix included: pack-smoke's post-restart status check was a
  single-shot probe that raced boot on a slow runner — replaced with the
  same bounded-retry primitive the new lanes use.

Sherlock §4 compliance: pull_request-only triggers, no secrets exposed to
either new job, third-party actions pinned to full commit SHAs, no PR-
controlled metadata interpolated into any run: block.

Local validation (real ephemeral Harper on this worktree's build): CLI-seeded
memory rows get a real embeddingModel at write time (confirmed embedding-stamp
doesn't contend with the synthetic migration across a baseline/PR-build swap
without needing FLAIR_EMBEDDING_MODEL pinning); migration state polling,
ledger hash-envelope parsing, and mid-flight JSON extraction logic all
verified against live HealthDetail/ledger output; ops-API `update` confirmed
to do a genuine partial-field merge (validates the corruption step); a full
`flair snapshot create` -> corrupt -> `flair snapshot restore` round trip
verified byte-identical content recovery. Full existing unit suite (2161
tests) green before and after (no runtime files touched).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@tps-flint tps-flint requested a review from a team as a code owner July 12, 2026 05:12

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kern Review — APPROVED (architecture; CI lane wiring needs the fix Flint is already working)

Reviewed the full diff (3 files, +844/-12). The architecture is sound and matches the ratified safety-doc invariants. CI has a wiring issue on the downgrade-and-revert lane (auth error during CLI seeding) that Flint is aware of and fixing. My review covers the architecture and the design-finding adjudication.

Downgrade-and-revert lane — architecturally correct

The scenario is exactly what I ratified: install baseline -> seed 140 rows -> swap PR build -> catch mid-flight -> kill -> reinstall baseline -> assert old binary serves correctly against partially-migrated store. The two-phase poll (wait_for_migration_mid_flight) mirrors the proven resume-after-kill test pattern. The bounded-retry discipline (wait_for_http_ok, wait_for_http_down, wait_for_flair_status_running) replaces every single-shot probe — the flair#691 fix generalized correctly. The self-diagnosing trap (dump_migration_diagnostics on ERR) means a red run explains itself without a separate evidence-gathering cycle. Correct.

The assertion is the honest bar: byte-identical content via raw ops-API read (version-stable, doesn't depend on either build's CLI/REST auth) PLUS a real flair memory search round-trip. Both paths must pass. Correct.

Snapshot-restore drill — architecturally correct

The design-finding adjudication (the key question Flint asked):

Concur with the as-built. The migration runner's internal pre-flight snapshot (resources/migrations/snapshot.ts) is risk-class-scoped by design — metadata-only for derived-only, schema+metadata for schema-additive. Neither writes row content, because content restore is unnecessary by construction (invariant I: source is never modified in place for these risk classes). The internal snapshot proves the pre-flight step fired; the actual content-restorable mechanism is flair snapshot create/restore, which is what the drill exercises.

The FLAIR-MIGRATION-SAFETY.md wording should be reconciled to clarify:

  • The internal pre-flight snapshot = the risk-scoped subset (metadata/schema/pointers), proving the boot-keyed safety step fired
  • The restorable-content path = physical snapshot (flair snapshot create/restore) or logical export (flair backup), which is the "floor beneath everything" from invariant III
  • For derived-only and schema-additive migrations, content restore is unnecessary by construction — the internal snapshot is sufficient
  • For content-transform migrations (none exist yet), the physical snapshot mechanism would need to be made automatic in the runner before the first one ships — this is a future requirement, not a current gap

Flint should fold this clarification into the safety doc. The as-built is correct for the current risk classes; the doc wording was written before the implementation revealed the two-mechanism split.

The drill itself: seed -> migrate to completion -> corrupt via ops-API partial update -> flair snapshot restore -> byte-identical verify. Correct. The corruption step uses a real partial-field merge (ops-API update), not a synthetic marker the restore path could special-case. Honest test.

Upgrade-lane migration assertions — architecturally correct

Three assertions, all the right ones:
(a) auto-migration ran (wait_for_migration_state "completed") — proves the boot-keyed runner actually fired, not just "boot didn't crash"
(b) content-hash envelope match=true (check_ledger_hash_envelope from OrgEvent) — invariant IV's completion gate
(c) recall parity (flair memory search finds the seeded ground truth) — the migration-touched data is still retrievable

Placed before the manual flair reembed step so the two writers don't race the same rows. Correct.

migration-lane-lib.sh — well-designed shared primitives

Every function is bounded-retry (never single-shot), prints progress to stdout, and includes last-observed state in timeout error messages. The constants (SYNTHETIC_MIGRATION_ID, RESERVED_TEST_AGENT_ID) are documented duplications of the TS module constants (CI shell can't import TS). The dump_migration_diagnostics function captures HealthDetail + state.json + hdb.log — everything needed for triage without a second evidence-gathering cycle. Good.

flair#691 fix — correct

Replacing single-shot post-restart status checks with bounded-retry (wait_for_flair_status_running) in both pack-smoke and upgrade-smoke. The root cause (a single flair status check immediately after flair restart returns races boot on a slow runner) is correctly diagnosed and the fix (bounded retry, same pattern the new lanes use) is correct. Generalized, not point-fixed.

Verdict

Architecture approved. The CI lane wiring issue (auth error on the downgrade-and-revert seeding path) is a test-fix iteration, not an architecture problem. The design-finding adjudication: concur with the as-built — the two-mechanism split (internal risk-scoped snapshot + physical flair snapshot for content restore) is correct for the current risk classes. Flint should fold the doc clarification. Approving.

@tps-kern tps-kern left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved — all lanes verified, design finding on invariant III adjudicated.

Lanes verified:

1. Downgrade-and-revert lane:
Full scenario: install baseline → seed 140 rows via real CLI → stop → swap in PR build with synthetic migration enabled + 2.5s batch delay → wait for genuinely mid-flight (rowsDone>0 AND rowsRemaining>0, bounded retry) → SIGTERM kill → reinstall baseline against partially-migrated data dir → assert old binary boots, row count matches, content byte-identical (both raw ops-API read AND real CLI search round-trip). This is the non-negotiable invariant I proof. The mid-flight catch is deterministic (not a single-shot probe — bounded two-phase poll, the #691 lesson applied). Error trap dumps diagnostics (HealthDetail + state file + hdb.log tail) on any failure. All third-party actions pinned to commit SHAs, no secrets exposed, pull_request trigger only (Sherlock §4 compliant).

2. Snapshot-restore drill:
Seed 60 rows → restart to trigger migration → wait for completion → assert internal pre-flight snapshot fired (complementary check) → take physical snapshot via flair snapshot create → record ground truth → deliberately corrupt all rows via raw ops-API update → flair snapshot restore → assert byte-identical content + row count. The drill uses the physical snapshot mechanism (the invariant III "floor beneath everything") and asserts the internal migration snapshot fired as a complementary check. Correct two-mechanism design.

3. Upgrade-lane migration assertions (test.yml extension):
Stub-stamped rows seeded via raw ops-API (bypasses Memory.post's regen on purpose — deliberate stale stamp regardless of embeddings readiness) → swap in PR build with FLAIR_ENABLE_TEST_MIGRATIONS=1 → wait for synthetic migration completion (bounded retry) → assert ledger hashEnvelopeMatch=true → assert recall parity (search finds migration-seeded ground truth). This is the CI-foundation §2 upgrade lane with teeth: it proves the auto-migration ran, the completion gate passed, and recall survived — not just "boot didn't crash."

4. Flake fix (#691):
Single-shot post-restart flair status check replaced with wait_for_flair_status_running (bounded retry, 90s timeout) in both pack-smoke and upgrade-smoke. The library function is shared (migration-lane-lib.sh) — same primitive the migration lanes use. Applied consistently, not just in one lane. The right generalization of the fix.

Design finding: invariant III wording vs as-built — CONCUR.

Flint's reading is correct. The as-built design is RIGHT for the registered migrations:

  • derived-only (embedding-stamp): metadata-only snapshot. No content to restore — embeddings are recomputable from source (invariant I). The internal pre-flight snapshot records row counts + versions for audit, not for content restoration. Correct.

  • schema-additive (synthetic CI variant): schema+metadata snapshot. No row rewrites — schema is additive, old rows keep their content. No content to restore beyond what the source data already holds. Correct.

  • content-transform (none exist yet): pointers+metadata snapshot. Old rows retained in-store via supersession (invariant I — never mutated in place). The internal snapshot records which rows were touched and how they chain. But CONTENT recovery for a content-transform migration requires the physical snapshot/export mechanism — the internal snapshot's pointer record proves WHICH rows were touched, not WHAT their content was before the transform.

So the invariant III wording should be amended to clarify:

  • The internal pre-flight snapshot is risk-class-scoped audit/proof, not a content-restorable backup for derived-only or schema-additive migrations. Content restoration is unnecessary by construction (invariant I — source data is never modified in place).
  • The restorable-content path is the physical snapshot (flair snapshot create/restore) or logical export (flair backup), which are the "floor beneath everything" mechanisms.
  • Content-transform migrations need the physical snapshot made automatic in the runner before the first one ships (the runner should call flair snapshot create automatically before a content-transform migration, not just the logical metadata snapshot). This is a future requirement, not a current gap — no content-transform migrations exist yet.

Amend the doc accordingly. The as-built is correct; the wording needs to match it.

Additional notes:

  • The shared library (migration-lane-lib.sh) is well-factored — every polling function is bounded-retry with last-state-on-timeout diagnostics, not single-shot. The constants duplication (SYNTHETIC_MIGRATION_ID, RESERVED_TEST_AGENT_ID) is documented and matches the TS module.
  • The trap ... ERR diagnostic dump pattern is good CI hygiene — any failing step automatically captures HealthDetail + state file + hdb.log tail before the step fails, uploaded as a failure-only artifact.
  • Pre-downloading the embedding model once and sharing it across baseline/PR-build boots (via FLAIR_MODELS_DIR) keeps the stamp identical across the swap — same lesson the resume-after-kill integration test learned, applied here at the CI lane level.

Ship it. Amend FLAIR-MIGRATION-SAFETY.md invariant III wording per the design finding.

@tps-sherlock tps-sherlock left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sherlock Review — APPROVED

Reviewed the full diff (3 files, +907/-47). All §4 rules confirmed compliant.

Trigger compliance ✓

on: push: [main], pull_request: [main], workflow_dispatch. No pull_request_target anywhere. The workflow header explicitly documents §4 compliance: "pull_request trigger only (never pull_request_target with PR-code checkout)." Correct.

Zero secrets ✓

No NPM_TOKEN, no deploy credentials, no secrets.* references. The only token is the default read-only GITHUB_TOKEN. The header documents: "public npm install + public GITHUB_TOKEN-default only." Correct.

Third-party actions SHA-pinned ✓

All five actions pinned to full commit SHAs:

  • actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
  • oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76
  • actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
  • socketdev/action@ba6de6cc0565af1f42295590380973573297e31f
  • actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02

No tag references. Correct.

No PR-metadata interpolation in run: blocks ✓

The only ${{ }} uses are:

  • ${{ steps.pack.outputs.TGZ_PATH }} — step output, not PR-controlled
  • ${{ github.workspace }} — runner-controlled, not PR-controlled

No ${{ github.event.pull_request.title }}, ${{ github.event.pull_request.body }}, or ${{ github.event.pull_request.head.ref }} anywhere. The header explicitly documents this. Correct.

No cache usage ✓

No actions/cache or restore-cache/save-cache steps. No cache poisoning surface.

Upgrade-lane supply-chain ✓

The lanes install the previously-published npm release (npm install -g @tpsdev-ai/flair@latest) in a credential-free context — no NPM_TOKEN, no deploy credentials. The @latest tag is a supply-chain risk (my design-review flag), but the lanes run on push: [main] and pull_request — not on a schedule that would auto-execute a compromised publish. The workflow_dispatch trigger is manual. Acceptable for now; pin to a specific version when the ratchet baseline stabilizes.

test.yml extension ✓

The flair#691 fix (bounded retry for flair status after restart) is a correctness fix, not a security change. The wait_for_flair_status_running helper is the same primitive the migration CI lanes use — one implementation, no drift. The upgrade-lane extension (stub-stamped rows + migration assertions) uses FLAIR_ENABLE_TEST_MIGRATIONS=1 — the same exact-match gate the synthetic migration uses. No new security surface.

flair#693 — hardcoded launchd label (separate, flagging)

The hardcoded ai.tpsdev.flair launchd label means a second flair init on the same host silently replaces the first instance's launchd plist — the second instance takes over the port, the first instance's data dir is orphaned but intact. This is a local DoS surface: an operator running flair init in a different directory (thinking they're creating a separate instance) silently kills the existing one.

Priority: medium. The data is safe (orphaned, not deleted), but the silent-replace behavior is operator-hostile and could cause production outages. The proposed fix (instance-scoped labels + refuse-to-touch-foreign-datadir guard) is the right approach. File as a separate security review when the fix PR lands.

Verdict

All §4 rules confirmed compliant. No pull_request_target, zero secrets, SHA-pinned actions, no PR-metadata in run: blocks, no cache surface. The upgrade-lane supply-chain risk is bounded (no scheduled trigger). Approving.

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.

3 participants