Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6efe48a
feat(e2e): scaffold migration harness runs and isolation probes
tobyhede Nov 9, 2026
3b5778d
fix(e2e): honor workflow build and filter invariants
tobyhede Nov 9, 2026
f6235d6
fix(e2e): enforce migration harness acceptance contracts
tobyhede Nov 9, 2026
d168a94
fix(e2e): keep harness reporting reachable and nonblocking
tobyhede Nov 9, 2026
899d121
docs(migrate): add the migrate glossary and the forced-pass obligatio…
tobyhede Oct 15, 2026
7e9e4dc
fix(e2e): enforce migration review acceptance and isolation contracts
tobyhede Nov 10, 2026
aaee2b8
chore(e2e): align harness runs with the tested migration guide
tobyhede Nov 11, 2026
162377e
fix(e2e): require Git in the isolated migration runtime
tobyhede Nov 11, 2026
a02aff2
Merge branch 'reconcile/design' into feat/migration-harness-run
tobyhede Nov 11, 2026
50179e7
Merge branch 'reconcile/design' into feat/migration-harness-run
tobyhede Nov 11, 2026
90f3848
fix(e2e): preserve syscall evidence across exec PID changes
tobyhede Nov 11, 2026
0cd45fe
Merge branch 'reconcile/design' into feat/migration-harness-run
tobyhede Nov 11, 2026
6ce1bf4
fix(e2e): attribute unprefixed strace lines to the sole surviving tracee
tobyhede Nov 11, 2026
d4e3b3b
fix(e2e): trust /workspace for Git inside the isolation image
tobyhede Nov 11, 2026
b3e2555
fix(e2e): allow metadata reads of a held descriptor in the isolation …
tobyhede Nov 11, 2026
6c7cab7
fix(e2e): reject false migration passes and bound supervision
tobyhede Nov 11, 2026
9aea4f9
fix(e2e): accept bounded supervisor stderr frames
tobyhede Nov 11, 2026
cb79bbd
fix(e2e): preserve tracee lifetime across late fork returns
tobyhede Nov 12, 2026
987fb75
fix(e2e): classify reported socket errors as denied connections
tobyhede Nov 12, 2026
1140bdc
fix(e2e): supply exact friction section identifiers to migrators
tobyhede Nov 12, 2026
62b0915
fix(e2e): retain explicit process identity in isolation traces
tobyhede Nov 12, 2026
9ed2ff1
fix(e2e): identify traced actor independently of strace helper children
tobyhede Nov 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/migration-harness.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Migration harness

on:
push:
branches: [main]
paths:
- 'e2e/migration-harness/**'
- 'e2e/vitest.harness.config.ts'
- 'e2e/package.json'
- 'e2e/tsconfig.json'
- 'turbo.json'
- 'tsconfig.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'packages/eql/**'
- '.github/workflows/migration-harness.yml'
pull_request:
paths:
- 'e2e/migration-harness/**'
- 'e2e/vitest.harness.config.ts'
- 'e2e/package.json'
- 'e2e/tsconfig.json'
- 'turbo.json'
- 'tsconfig.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'packages/eql/**'
- '.github/workflows/migration-harness.yml'
workflow_dispatch:

permissions:
contents: read

jobs:
probes:
name: Run skeleton and isolation probes
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
run_install: false
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
with:
node-version: 22
cache: pnpm
- run: npm install -g node-gyp
- run: pnpm install --frozen-lockfile
- run: pnpm exec turbo run harness:selftest harness:typecheck harness:isolation-probe harness:store-probe --filter @cipherstash/e2e
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,5 @@ languages/golang/auth/wasm/*.sha256
# wasm:guest:build:deterministic`; under testdata so no binary embeds it.
languages/golang/encrypt/testdata/*.wasm
languages/golang/encrypt/testdata/*.sha256
# Raw migration harness reports, transcripts, credentials and workspace copies.
.migration-harness-runs/
2 changes: 2 additions & 0 deletions CONTEXT-MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
- [Stack Encrypt](./packages/stack-encrypt/CONTEXT.md) — encrypts values under
per-value ZeroKMS data keys and derives searchable terms from them, for Rust
and, through a WASI guest, Go.
- [Migration Harness](./e2e/migration-harness/CONTEXT.md) — tests the
Protect.js upgrade guide by having agents follow it and judging the result.
- [Migrate](./languages/typescript/packages/migrate/CONTEXT.md) — moves an
existing column between plaintext and encrypted form, resumably, for the
Stack CLI and customer workers.
Expand Down
4 changes: 4 additions & 0 deletions e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ The native build needs the repository's Rust toolchain and trusted mise configur

See the [migration guide](../docs/migration/protect-dynamodb-to-stack.md) for the tested scope and limitations.

## Migration Harness

The [Migration Harness](migration-harness/run/README.md) runs an isolated Fixture App upgrade and records a Run Report. Its self-tests and credential-free Docker probes use dedicated commands and a dedicated CI workflow; ordinary `pnpm test` and `test:e2e` do not invoke them. A real Run additionally needs CipherStash credentials. See the harness README for commands that build workspace dependencies first and for the remaining acceptance limitations.

## Adding a new e2e test

- File name must end in `.e2e.test.ts` to be picked up by `vitest.config.ts`.
Expand Down
20 changes: 20 additions & 0 deletions e2e/migration-harness/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,3 +111,23 @@ _Avoid_: run metadata
**Migrator Environment**:
The execution environment for a Migrator's commands.
_Avoid_: Migrator sandbox

**Isolation Actor**:
The policy identity attached to a restricted process and its descendants: Migrator or App Host. This differs from a database role, which grants access to a Store Set.
_Avoid_: worker, actor without its isolation context

**Runtime Supervisor**:
The trusted process outside an Isolation Actor that observes filesystem and network access, terminates it after a successful forbidden access and blocks later calls.
_Avoid_: application monitor

**Isolation Capability Probe**:
A harness-controlled check that demonstrates an enforced boundary and its trusted event stream before application execution.
_Avoid_: application-reported isolation check

**Trusted Access Event**:
A Runtime Supervisor record identifying the Isolation Actor, resource, policy decision and observed access result. Denied access differs from successful forbidden access.
_Avoid_: agent-reported verdict

**Evidence Record**:
A harness-assigned record with an immutable Run-scoped ID, producing stage, stable check or tool-call ID, payload digest, guide revision and attribution eligibility.
_Avoid_: unstructured evidence blob
109 changes: 109 additions & 0 deletions e2e/migration-harness/__tests__/behaviour.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { behaviour, legacy, mutation } from '../run/behaviour.mjs'
import { requiredOracleChecks } from '../run/migration-checks.mjs'
import { run } from '../run/run.mjs'

test('Run observes Legacy Data before new writes and mutation through App Host channel', async () => {
const rows = new Map<string, Record<string, unknown>>([
[legacy.pk, legacy],
[mutation.pk, mutation],
])
const postgres = new Map<string, Record<string, unknown>>([
[legacy.pk, legacy],
[mutation.pk, mutation],
])
const methods: string[] = []
const project = (row: Record<string, unknown> | undefined) =>
row &&
Object.fromEntries(['pk', 'email', 'age', 'notes'].map((k) => [k, row[k]]))
async function call(method: string, args: unknown[]) {
methods.push(method)
const [value] = args
if (method === 'get') return rows.get(String(value)) ?? null
if (method === 'getPostgres')
return project(postgres.get(String(value))) ?? null
if (method === 'put') {
if (value === null || typeof value !== 'object')
throw Error('Expected customer')
const customer: Record<string, unknown> = Object.fromEntries(
Object.entries(value),
)
rows.set(String(customer.pk), customer)
return
}
if (method === 'setActive') {
rows.set(String(value), { ...rows.get(String(value)), active: args[1] })
return
}
if (method === 'copyToPostgres') {
const row = rows.get(String(value))
if (!row) throw Error('Missing source')
postgres.set(String(value), row)
return
}
if (method === 'copyToDynamo') {
rows.set(String(value), {
...rows.get(String(value)),
...postgres.get(String(value)),
})
return
}
const fields: Record<string, string> = {
findEmail: 'email',
findEmailLower: 'emailLower',
findAge: 'age',
findBalance: 'balance',
findActive: 'active',
findPostgresEmail: 'email',
findPostgresAge: 'age',
}
const source = method.startsWith('findPostgres') ? postgres : rows
return [...source.values()]
.filter((row) =>
method === 'findEmailLower'
? String(row.emailLower).toLowerCase() === String(value).toLowerCase()
: row[fields[method]] === value,
)
.map((row) => (method.startsWith('findPostgres') ? project(row) : row))
}
const report = await run({
id: 'behaviour',
manifest: { guideHash: 'guide' },
driver: {
provision: async () => {},
installUnmigrated: async () => {},
initializeLegacyData: async () => {},
snapshot: async () => {},
migrate: async () => ({ exitCode: 0 }),
friction: async () => [],
installAppHost: async () => {},
behaviour: async () => [
...(await (await behaviour(call)).mutate()),
...requiredOracleChecks.map((id) => ({ id, passed: true })),
],
},
})
assert.equal(report.outcome, 'pass')
assert.equal(
report.evidence.filter((e) => e.kind === 'oracle-check').length,
19 + requiredOracleChecks.length,
)
assert.ok(methods.indexOf('put') > methods.indexOf('findPostgresAge'))
})
test('fresh-write lookup failure is visible as stable Oracle evidence', async () => {
const result = await behaviour(async (method: string) => {
if (method.startsWith('find')) return []
if (method === 'get') return null
return null
})
const checks = await result.mutate()
assert.deepEqual(
checks.find((check) => check.id === 'behaviour.fresh-findBalance'),
{
id: 'behaviour.fresh-findBalance',
passed: false,
cause: 'Unexpected application result',
},
)
})
77 changes: 77 additions & 0 deletions e2e/migration-harness/__tests__/cli-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { execFile } from 'node:child_process'
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { expect, test, vi } from 'vitest'
import { requiredOracleChecks } from '../run/migration-checks.mjs'

const observed = vi.hoisted(() => ({ root: '' }))
vi.mock('../run/real-driver.mjs', () => ({
createRealDriver: (options: { root: string }) => {
observed.root = options.root
return {
provision: async () => {},
installUnmigrated: async () => {},
initializeLegacyData: async () => {},
snapshot: async () => {},
migrate: async () => ({ exitCode: 0 }),
friction: async () => [],
installAppHost: async () => {},
behaviour: async () => [
{ id: 'behaviour.cli', passed: true },
...requiredOracleChecks.map((id) => ({ id, passed: true })),
],
close: async () => {},
}
},
}))
vi.mock('../isolation.mjs', () => ({
createIsolation: () => ({ drain: () => [], stop: async () => {} }),
}))

test('CLI resolves the checkout root and writes a report with its real Fixture App revision', async () => {
const root = fileURLToPath(new URL('../../../', import.meta.url)).replace(
/\/$/,
'',
)
const output = await mkdtemp(join(tmpdir(), 'mh-cli-root-'))
const argv = process.argv
const exitCode = process.exitCode
const diagnostics = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
process.argv = ['node', 'cli.mjs', '--migrator', 'fake', '--output', output]
const expectedRevision = (
await promisify(execFile)('git', ['rev-parse', 'HEAD'], { cwd: root })
).stdout.trim()
let failure: unknown
try {
await import('../run/cli.mjs')
} catch (error) {
failure = error
}
expect(
failure,
'CLI must reach the Run and report stages from the actual checkout',
).toBeUndefined()
expect(observed.root).toBe(root)
expect(
(
await stat(
join(observed.root, 'e2e/migration-harness/fixture/src/app.mjs'),
)
).isFile(),
).toBe(true)
const report = JSON.parse(
await readFile(join(output, 'run-report.json'), 'utf8'),
)
expect(report.manifest.fixtureRevision).toBe(expectedRevision)
expect(report.outcome).toBe('pass')
} finally {
process.argv = argv
process.exitCode = exitCode
diagnostics.mockRestore()
await rm(output, { recursive: true, force: true })
}
})
Loading
Loading