Skip to content

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

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

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

Conversation

@tobyhede

@tobyhede tobyhede commented Nov 12, 2026 •

Copy link
Copy Markdown

Summary

Add the customer Protect-to-Stack guide for DynamoDB and PostgreSQL, targeting Stack 1.2.1 and EQL v3. The PostgreSQL script is a verbatim port of the canonical executable conversion, with independent Protect-to-Stack plaintext verification.

Changes

  • Add the guide beside plaintext migration in navigation, retaining supported mappings, maintenance-window requirements and recovery limits.
  • Synchronize the complete source procedure at e7d6a02d3d92f466e57175711efb56e9f4bdf544, guide SHA-256 6e730d48fdba975c55273f3c9d58f27c21928babe8607c862a6b0eca996bc242.
  • Explain the separate pinned migration tool retaining Protect for source validation, bulk verification and catalog preflight. Keep immutable executable links at 21631ecf68fd5356fdd4c2631550a46931123dc4.
  • Preserve the customer rehearsal checklist unchanged; it adds deployment acceptance checks without extending tested compatibility claims.
  • Retain the narrow content-validator allowance for the legacy source type eql_v2_encrypted.

Verification

  • Page head 5259b44995b09ab7d3cac62003e77d3b68c58c00 is signed and verified.
  • Normalized source-body equality passes, reversing only MDX fence metadata and HTML-to-JSX synchronization markers. The conversion code is verbatim identical to the canonical module; the customer checklist is unchanged.
  • MDX parsing, types, content validation, internal links, scoped Biome, whitespace and production build pass.
  • Full lint retains two verified pre-existing formatting failures: content/docs/security/meta.json and scripts/fixtures/stash-manifest.json.
  • Source repair passes 74 focused tests, E2E typechecking and strict compilation against the pinned published packages. Live source E2E at a6d33c9e passes 14 guide tests on each of PostgreSQL 17 and 18, including execution of the exact pasted script, dropped-column setup, both restricted search paths and missing-primary-key refusal. This later commit changes only the test fixture; the page's pinned guide and conversion bytes are unchanged.
  • Current docs CI passed. The earlier PG17 fixture failure reproduced once and is retained in the source PR's verification history.
  • Prior model runs used guide hash 46106cc0…; they are historical and do not validate this revised script. No new model run, customer staging migration or AWS deployment test was performed for this port.
  • Docs search CI targets main and builds the site; the separate Docs workflow targets v2. Local results above are explicit verification, not a claim about an uninspected remote run.

Related

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

Review notes

The page targets EQL v3, not v4. Publication and customer-specific staging/cutover/rollback rehearsal remain separate gates. This PR has not been merged or deployed.

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 4:50am 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

Keep the customer page aligned with the generated and executable source guide. Source plaintexts now come from Protect, the standalone migration tool retains both pinned SDKs, and the code is checked for strict types and drift instead of maintained as a handwritten copy.

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

This branch was successfully deployed

1 active deployment
Preview — 5259b449 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