Skip to content

docs(guides): add the Protect-to-Stack migration procedure - #118

Open
tobyhede wants to merge 2 commits into
mainfrom
docs/protect-to-stack-migration
Open

tobyhede wants to merge 2 commits into
mainfrom
docs/protect-to-stack-migration

Conversation

@tobyhede

@tobyhede tobyhede commented Nov 12, 2026 •

Copy link
Copy Markdown

Summary

Add a customer guide for upgrading existing Protect applications to Stack 1.2.1 and EQL v3 on DynamoDB and PostgreSQL. It preserves the tested procedure's supported mappings, verification steps and rollback boundaries, and adds an explicit checklist for rehearsal against the customer's own deployment.

Changes

  • Add /guides/protect-to-stack beside the existing plaintext data migration guide in navigation.
  • Port the complete procedure from cipherstash/stack at 0ecabc3f6, with immutable source/example links and code-fence filename metadata. Record the source revision and SHA-256.
  • Include the source guide's complete PostgreSQL dependency preflight and clarification of the existing refusal on a repeated successful conversion.
  • Keep the added customer staging checklist separate from the source procedure: inventory and exceptions, actual AWS tables/indexes/pagination/concurrency, PostgreSQL schema/volume/write pauses, exact query results, new writes and recovery after cutover.
  • Exempt this exact page from only the content gate's legacy eql_v2_encrypted type rule, since it must identify retained source columns. All other rules continue to apply.

Verification

Fresh independently audited guide-only runs used source guide SHA-256 46106cc0a71dbeb757bcb0b3889c6fc5741bac201afe807ed8cde0a035d3d649 and harness 5261d3a5c0ae6a4750a7a1102d08436a1d314725:

  • DynamoDB: 24 checks passed, 11 turns, empty friction report.
  • PostgreSQL: 15 checks passed, 9 turns, empty friction report.
  • Complete broker command/response evidence was reviewed; neither run retrieved supplementary documentation.
  • Runs used Claude Code 2.1.322 with claude-sonnet-5-5 on the existing Colima Linux host, outside CI.
  • This is one sample per store, not the ADR's convergence criterion, an actual AWS deployment test, or customer staging acceptance.
  • Source guide current-head CI passed. A separate intermittent harness observer failure remains under repair; these results do not claim all latest harness CI is green.

Latest synchronized page at 33e9218:

  • MDX compilation, normalized source comparison, bun run types:check, content and internal-link validation passed.
  • Production next build passed on retry using the previously generated references. The first attempt reported an async panic without a source diagnostic; it did not reproduce.
  • The source guide update passed 58 citation tests and its E2E typecheck. No new runtime behavior was introduced.

Initial publication preparation at ad770c3:

  • bun install --frozen-lockfile passed; sandbox temp-directory denial required an approved retry.
  • bun run build passed, including reference generation, content/diagram/link/redirect validation, production compilation and TypeScript. Unrelated tracked EQL generator output was restored afterward.
  • bun run types:check passed on an approved retry. The initial sandbox run produced an empty generated module and consequently failed type checking.
  • Final-tree content, link and redirect checks passed: 114 MDX pages checked; 621 legacy redirects resolve.
  • All 8 Mermaid diagrams parse. The new page independently compiles as MDX, and its two local anchors resolve.
  • A normalized comparison confirms the complete source procedure is identical after reversing immutable link substitutions and fence filename metadata.
  • Scoped Biome and git diff --check pass. Full bun run lint still reports two pre-existing formatting failures in content/docs/security/meta.json and scripts/fixtures/stash-manifest.json; both were reproduced from unchanged HEAD contents.
  • Commits ad770c3 and 33e9218 have verified signature status G.
  • No customer staging migration or AWS deployment test was performed. The independent source-guide model runs are recorded above; the documentation port itself changes no database or application behavior.

Related

Refs cipherstash/stack#1540, cipherstash/stack#1604, cipherstash/stack#1620.

Review notes

The frozen source guide remains unchanged. This page targets Stack 1.2.1 and EQL v3; it makes no EQL v4 compatibility claim. The added rehearsal checklist does not expand the proven mappings or claim customer acceptance has completed. This is a draft publication PR; it has not been merged or deployed.

The repository's Docs workflow currently targets v2, while its default branch is main; the local validation above is explicit evidence rather than a claim that this PR triggered that workflow.

Customers upgrading existing Protect data need a store-specific procedure distinct from plaintext backfill. Port the verified Stack guide at its immutable source revision, preserve its compatibility and rollback boundaries, and add explicit customer staging acceptance checks.

Refs cipherstash/stack#1540, cipherstash/stack#1604.
@vercel

vercel Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
public-docs Ready Ready Preview Nov 12, 2026 1:17am UTC

Request Review

Pin the customer page to the source revision that includes dependency checks directly and explains safe refusal on repeated conversion. Keep the added customer rehearsal checklist separate from the verified procedure.

Refs cipherstash/stack#1620.

@tobyhede tobyhede left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Review attestation

Reviewed 33e9218 · posted from Claude Code · claude-opus-5-5 · effort medium
Sources: code-review · claude-opus-5-5
Skills: code-review

Findings (8): 8 minor — 8 inline, 0 below.

```typescript filename="migration-example-8.ts"
import postgres from 'postgres'

const sql = postgres(process.env.DATABASE_URL)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

minor postgres(process.env.DATABASE_URL) with an unset URL falls back to the libpq PG* defaults, so the script can take ACCESS EXCLUSIVE and run DDL against whatever database those point at. Throw if DATABASE_URL is missing before connecting.

Source: code-review · claude-opus-5-5

p.ob != null || p.bf != null ||
(column === 'notes' ? p.hm != null : typeof p.hm !== 'string'))
throw new Error(`Unsupported legacy payload in ${column}`)
const plain = ok(await client.decrypt(p), 'decrypt source')

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

minor Under ACCESS EXCLUSIVE the script makes about 11 serial ZeroKMS calls per row (three columns), and step 6 decrypts every source value again even though step 2 already produced plain. At 10k rows and ~50 ms per call that is roughly 90 minutes of table lock. Keep the step-2 plaintexts for the step-6 comparison and use bulkDecrypt and batch encryptQuery.

Source: code-review · claude-opus-5-5

const term = ok(await client.encryptQuery(plain, {
table: customers, column: customers[column],
}), 'recompute term')
if (term?.hm !== p.hm) throw new Error(`Equality term mismatch in ${column}`)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

minor term?.hm reads a member of the encryptQuery union without narrowing, which the guide's own hmacOf example says to do, so strict tsc rejects it; function ok(result, step) is also implicit any. Narrow with hmacOf and type ok.

Source: code-review · claude-opus-5-5


The executable example accepts one primary key and the three source columns above. It refuses secondary indexes, non-primary constraints, dependent views, user triggers, row-level security and inheritance. Those objects need a reviewed migration of their own before using this example. Do not drop them merely to make preflight pass (`postgres-upgrade › refuses customer views before changing source data or columns`).

For an existing database, retain its v2 SQL installation. Install v3 alongside it using the current `stash eql install` against the rehearsed database. Do not uninstall v2 or drop its schemas during this procedure. Capture and verify existing indexes and dependencies around installation, as well as around data conversion.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

minor The guide pins Stack 1.2.1 and EQL v3 but installs with an unpinned "current stash eql install" (also lines 269 and 482), so a later stash release can install SQL this procedure was never tested against. Pin the CLI, e.g. npx --package=stash@X.Y.Z stash eql install.

Source: code-review · claude-opus-5-5

EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid = 'customers'::regclass AND NOT tgisinternal)
OR EXISTS (SELECT 1 FROM pg_constraint WHERE (conrelid = 'customers'::regclass OR confrelid = 'customers'::regclass)
-- PostgreSQL 18 also lists the primary key column's NOT NULL here.
AND NOT (contype IN ('p', 'n') AND conrelid = 'customers'::regclass AND conkey = ARRAY[1]::smallint[]))

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

minor The preflight hardcodes the primary key as attribute 1 (conkey = ARRAY[1], and attnum > 1 at line 361). On a table where a column before pk was dropped, pk has attnum 2 and a valid table is refused as having unsupported constraints. Compare against pk's actual attnum.

Source: code-review · claude-opus-5-5

- `postgres-upgrade` is `e2e/tests/protect-postgres-upgrade.e2e.test.ts`.
- `example` is `e2e/tests/protect-guide-example.e2e.test.ts`.

Both suites produce item objects with the real Protect.js versions above, installed from npm, and read them back with the in-tree Stack package (currently 1.2.1). They test live encryption, but do not contact DynamoDB: item persistence and HMAC matching are simulated in memory. The crossover suite also runs real Postgres inserts and SQL queries. This verifies adapter compatibility, not AWS expressions, GSI configuration or a deployed customer application. They run on every pull request in the `e2e-tests` job of `.github/workflows/tests.yml`. Both need `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY` and `CS_CLIENT_ACCESS_KEY`, supplied in the environment, `e2e/.env` or the repository-root `.env`. The crossover suite also needs `DATABASE_URL` for a disposable Postgres database where it can install EQL and create tables. From an installed repository checkout, build the native binding and run through Turbo so workspace dependencies are built:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

minor "Both suites produce item objects…" follows a list of four suites, and postgres-upgrade produces no DynamoDB items, so the in-memory-persistence claim appears to cover it. Name the suites each sentence applies to.

Source: code-review · claude-opus-5-5

Both suites produce item objects with the real Protect.js versions above, installed from npm, and read them back with the in-tree Stack package (currently 1.2.1). They test live encryption, but do not contact DynamoDB: item persistence and HMAC matching are simulated in memory. The crossover suite also runs real Postgres inserts and SQL queries. This verifies adapter compatibility, not AWS expressions, GSI configuration or a deployed customer application. They run on every pull request in the `e2e-tests` job of `.github/workflows/tests.yml`. Both need `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY` and `CS_CLIENT_ACCESS_KEY`, supplied in the environment, `e2e/.env` or the repository-root `.env`. The crossover suite also needs `DATABASE_URL` for a disposable Postgres database where it can install EQL and create tables. From an installed repository checkout, build the native binding and run through Turbo so workspace dependencies are built:

```bash
pnpm --filter @cipherstash/protect-ffi run build:native

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

minor A developer-audience page publishes maintainer-only commands (build:native with Rust and mise, Turbo tasks in the stack repo) that fail in a customer's app; line 47 itself says they are for maintainers. Move them to the stack repo and link from here.

Source: code-review · claude-opus-5-5

{
id: "eql-v2-encrypted-type",
// This upgrade guide must identify the retained legacy source columns.
exempt: ["content/docs/guides/protect-to-stack.mdx"],

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

minor exempt turns eql-v2-encrypted-type off for the whole page, against the Rule docstring that limits it to pages documenting a component that still ships the API. A later edit teaching eql_v2_encrypted for new columns would pass. Use the line-level RETIRED_MARKER on the lines naming legacy source columns instead.

Source: code-review · claude-opus-5-5

This branch was successfully deployed

1 active deployment
Preview — 33e92186 Deployed Nov 12, 2026 by vercel[bot]
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