Getting started
Local lifecycle cookbook — full listing
The complete runnable source of the local lifecycle walkthrough, generated from the SDK's shipped example.
Save this listing as local-lifecycle.mjs in a directory with @fabricorg/airlift
installed (npm install @fabricorg/airlift), then run
node local-lifecycle.mjs ./cookbook-out with Node.js 22+.
// Local lifecycle cookbook for the Fabric Airlift SDK's in-memory runtime.
//
// Runnable, end-to-end walkthrough of the governed migration lifecycle using only
// the published package surface: `createAirliftRuntime()` composed with
// `createAirliftConfiguredAuthorization()` — the composition intended for unit
// tests, examples, and local demos. Every mutation goes through the governed
// action pipeline; nothing here writes to a real workspace.
//
// Usage:
// node examples/local-lifecycle.mjs [output-directory]
//
// The script exits non-zero if any expected-success step fails or any
// expected-denial unexpectedly succeeds. It writes the minted migration
// certificate envelope and the Ed25519 public key to the output directory
// (default: a temporary directory) and prints the offline verification command.
import { generateKeyPairSync } from 'node:crypto';
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
AIRLIFT_ACTION_IDS,
AIRLIFT_ACTION_MANIFEST_GENERATION,
AIRLIFT_PLATFORM_TENANT,
configuredAirliftEvidenceServices,
createAirliftConfiguredAuthorization,
createAirliftRuntime,
createEd25519EvidenceSigner,
createEd25519EvidenceVerifier,
defaultValidationProfile,
readinessSnapshot,
verifyMigrationCertificateEnvelope,
} from '@fabricorg/airlift';
// ── Generation pin ───────────────────────────────────────────────────────────
// This cookbook is written against action manifest generation 11. A generation
// bump changes governed action contracts; re-verify every step before bumping.
if (AIRLIFT_ACTION_MANIFEST_GENERATION !== 11) {
console.error(
`This cookbook is versioned against action manifest generation 11; the installed @fabricorg/airlift reports generation ${AIRLIFT_ACTION_MANIFEST_GENERATION}. Check the package changelog before trusting these steps.`,
);
process.exit(1);
}
// ── Cast of principals ───────────────────────────────────────────────────────
// Roles are deliberately narrow. Natural persons hold tenant roles; system and
// agent principals are admitted only through the trusted-id lanes below.
const ORG = 'acme-analytics';
const OPERATOR = { actorId: 'operator@acme.example', actorType: 'natural_person' };
const APPROVER = { actorId: 'approver@acme.example', actorType: 'natural_person' }; // distinct person: separation of duties
const WORKER = { actorId: 'svc-airlift-worker', actorType: 'system' };
const AGENT = { actorId: 'agent-convert-01', actorType: 'agent' };
const INSTALLER = { actorId: 'svc-airlift-installer', actorType: 'system' };
// ── Configured authorization ─────────────────────────────────────────────────
// The directory is install-time configuration, not request input:
// - memberships bind natural-person principals to tenant roles,
// - trustedWorkerIds admits system principals to the narrow worker transition set,
// - trustedAgentIds admits agents only to a bounded action set (assessment,
// dependency mapping, plan generation, inventory, conversion, artifacts,
// residue, discrepancy triage, modernization recommendations) — never
// approvals, certification, or cutover,
// - platformInstallerIds admits system principals to the org-registry bootstrap
// boundary (org_provision / org_retire in the platform tenant only),
// - admittedValidationPrincipals lists who may submit independently produced
// validation evidence, per organization and provider.
const VALIDATION_RUN_REF = 'jobs/run/2002';
const VALIDATION_EVIDENCE_REF = 'volumes/evidence/wave-1/fact_sales.json';
const VALIDATION_EVIDENCE_DIGEST = 'a'.repeat(64);
const VALIDATION_ARTIFACT_DIGEST = 'b'.repeat(64);
const authorizationConfig = {
memberships: [
{
// The delivery lead holds `admin` here so the separation-of-duties denial
// below is decided by the governance policy (resolver ≠ reviewer), not
// masked by a missing role permission. Production teams typically split
// the narrower operator / approver / validator roles across people.
organizationId: ORG,
principal: OPERATOR.actorId,
principalType: 'natural_person',
role: 'admin',
},
{
organizationId: ORG,
principal: APPROVER.actorId,
principalType: 'natural_person',
role: 'approver',
},
],
trustedWorkerIds: [WORKER.actorId],
trustedAgentIds: [AGENT.actorId],
platformInstallerIds: [INSTALLER.actorId],
admittedValidationPrincipals: [
{ organizationId: ORG, principal: WORKER.actorId, provider: 'experiments' },
],
};
// ── Runtime services ─────────────────────────────────────────────────────────
// The evidence registry stands in for a provider's authoritative run API: the
// verifier admits a validation run only when every field (producer, provider,
// run ref, evidence ref, digest) matches an immutable registry entry, so a
// caller's digest is never trusted merely because it is well formed.
//
// Binding and attestation are different claims, and this manifest only does the
// first. The entry below BINDS the run's artifact digest and verdict: a run
// submitted with either one different fails verification, so neither can be
// restated after the fact. It does not ATTEST them — a static manifest never sees
// the provider's evidence body, so it cannot derive what the provider reported and
// check the submission against it.
//
// That is sufficient here because this estate is Synapse, outside the governed
// hazard profile set. A governed conversion additionally requires provider-ESTABLISHED
// `verdict` and `completedAt`, and only a verifier that reads the provider's evidence
// body can establish them — the worker's Volume-backed store derives both from the
// digest-checked document. A static manifest cannot, no matter how complete, so a
// governed conversion fails closed until such a verifier is injected. The artifact
// digest is never attested by anyone: provider run documents do not name the artifact
// they exercised, so the conversion-hazard gate corroborates it at certification.
//
// The Ed25519 signer signs migration-certificate envelopes so they verify offline.
const { publicKey, privateKey } = generateKeyPairSync('ed25519');
const SIGNING_KEY_ID = 'cookbook-local-2026';
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
const runtime = createAirliftRuntime({
authorization: createAirliftConfiguredAuthorization(authorizationConfig),
services: {
...configuredAirliftEvidenceServices(authorizationConfig, [
{
organizationId: ORG,
producerPrincipal: WORKER.actorId,
provider: 'experiments',
providerRunRef: VALIDATION_RUN_REF,
evidenceRef: VALIDATION_EVIDENCE_REF,
evidenceDigest: VALIDATION_EVIDENCE_DIGEST,
artifactDigest: VALIDATION_ARTIFACT_DIGEST,
verdict: 'passed',
},
]),
airliftEvidenceSigner: createEd25519EvidenceSigner({
keyId: SIGNING_KEY_ID,
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
}),
},
});
// ── Invocation helpers ───────────────────────────────────────────────────────
// Idempotency keys make every mutation safely replayable: re-invoking with the
// same key collapses onto the original invocation instead of duplicating it.
// Convention: a stable `<demo>:<step>[:<subject>]` string per logical mutation.
let failures = 0;
async function ok(actionId, tenantId, actor, params, idempotencyKey) {
const result = await runtime.invokeAction(actionId, {
tenantId,
actorId: actor.actorId,
actorType: actor.actorType,
params,
...(idempotencyKey ? { idempotencyKey } : {}),
});
if (!result.ok) {
failures += 1;
console.error(`✗ ${actionId} failed: [${result.stage}] ${result.error}`);
process.exit(1);
}
console.log(`✓ ${actionId}`);
return result;
}
async function denied(label, expected, actionId, tenantId, actor, params) {
const result = await runtime.invokeAction(actionId, {
tenantId,
actorId: actor.actorId,
actorType: actor.actorType,
params,
});
if (result.ok) {
failures += 1;
console.error(`✗ EXPECTED DENIAL but ${actionId} succeeded: ${label}`);
process.exit(1);
}
// A denial only proves the named boundary if it fired for the expected reason.
if (result.stage !== expected.stage || !result.error.includes(expected.errorIncludes)) {
failures += 1;
console.error(
`✗ ${actionId} was denied for the WRONG reason: expected [${expected.stage}] …${expected.errorIncludes}…, got [${result.stage}] ${result.error}`,
);
process.exit(1);
}
console.log(`✓ denied as expected — ${label}\n [${result.stage}] ${result.error}`);
return result;
}
// ── 0. Platform-tenant bootstrap ─────────────────────────────────────────────
// Organizations are minted from the platform registry tenant, never from an org
// tenant. The installer boundary requires ALL of: tenantId = AIRLIFT_PLATFORM_TENANT
// ('airlift-platform'), a system actor listed in platformInstallerIds, and
// {orgId, name} params. Invoking org_provision with the org's own tenantId is the
// classic trap: the installer grant is tenant-bound, so entitlement fails with
// `Module "airlift" is not enabled for tenant …` — a boundary, not a defect.
console.log('\n— Platform bootstrap —');
await denied(
'org_provision from the org tenant (the installer grant is platform-tenant-bound)',
{ stage: 'handler', errorIncludes: 'is not enabled for tenant' },
AIRLIFT_ACTION_IDS.orgProvision,
ORG,
INSTALLER,
{ orgId: ORG, name: 'Acme Analytics' },
);
await ok(
AIRLIFT_ACTION_IDS.orgProvision,
AIRLIFT_PLATFORM_TENANT,
INSTALLER,
{ orgId: ORG, name: 'Acme Analytics' },
'cookbook:org',
);
// ── 1. Estate ────────────────────────────────────────────────────────────────
// The estate is the source system under migration. `environment: 'prod'` engages
// the full gate policies (dev estates get the approval-skip edge).
console.log('\n— Discovery and planning —');
const estate = await ok(
AIRLIFT_ACTION_IDS.estateRegister,
ORG,
OPERATOR,
{
name: 'Legacy Synapse DW',
sourceSystem: 'synapse',
owner: 'data-platform',
environment: 'prod',
},
'cookbook:estate',
);
const estateId = estate.data.estateId;
// Idempotent replay: within the same runtime, re-invoking with the same key
// collapses onto the original invocation instead of double-applying. (The
// in-memory store lives only as long as this process; the durable Postgres
// store gives the same keys cross-process replay semantics.)
const replay = await ok(
AIRLIFT_ACTION_IDS.estateRegister,
ORG,
OPERATOR,
{
name: 'Legacy Synapse DW',
sourceSystem: 'synapse',
owner: 'data-platform',
environment: 'prod',
},
'cookbook:estate',
);
if (replay.data.estateId !== estateId) {
console.error('✗ idempotent replay minted a second estate instead of collapsing');
process.exit(1);
}
console.log(`✓ replay with the same idempotency key returned the original estate ${estateId}`);
// ── 2. Engagement ────────────────────────────────────────────────────────────
// Engagements scope commercial services over estates; discovery and factory
// actions require an active engagement.
const engagement = await ok(
AIRLIFT_ACTION_IDS.engagementCreate,
ORG,
OPERATOR,
{
name: 'Synapse modernization wave 1',
services: ['discovery', 'migration_factory'],
owner: OPERATOR.actorId,
estateIds: [estateId],
},
'cookbook:engagement',
);
const engagementId = engagement.data.engagementId;
await ok(AIRLIFT_ACTION_IDS.engagementActivate, ORG, OPERATOR, { engagementId }, 'cookbook:engage');
// ── 3. Assessment ────────────────────────────────────────────────────────────
// The assessment records the analyzer run (tool, report refs, digests, counts).
const assessment = await ok(
AIRLIFT_ACTION_IDS.assessmentRecord,
ORG,
OPERATOR,
{
estateId,
jobRunRef: 'jobs/run/assessment-1',
reportRef: 'volumes/discovery/assessment.json',
reportDigest: 'b'.repeat(64),
inventoryDigest: 'c'.repeat(64),
dependencyDigest: 'd'.repeat(64),
toolVersion: 'lakebridge@0.14.2',
objectCounts: { tables: 2, views: 1, storedProcedures: 0, etlJobs: 0, other: 0 },
},
'cookbook:assessment',
);
const assessmentId = assessment.data.assessmentId;
// ── 4. Inventory + dependency graph accept ───────────────────────────────────
// Objects are registered against the assessment; the dependency graph is loaded
// by a trusted agent and accepted by a human with an explicit expected edge
// count, then the assessment itself is accepted with the expected object count.
// Acceptance freezes scope digests — later drift is detectable.
const objectIds = [];
for (const row of [
{ name: 'dbo.fact_sales', objectType: 'table', complexity: 'medium' },
{ name: 'dbo.dim_customer', objectType: 'table', complexity: 'low' },
{ name: 'dbo.vw_sales_by_region', objectType: 'view', complexity: 'medium' },
]) {
const object = await ok(
AIRLIFT_ACTION_IDS.objectRegister,
ORG,
OPERATOR,
{
estateId,
name: row.name,
objectType: row.objectType,
sourcePath: `dw/${row.name}.sql`,
complexity: row.complexity,
assessmentRef: assessmentId,
},
`cookbook:obj:${row.name}`,
);
objectIds.push(object.data.objectId);
}
const graph = await ok(
AIRLIFT_ACTION_IDS.dependencyGraphStart,
ORG,
AGENT,
{
engagementId,
assessmentId,
expectedEdgeCount: 2,
graphRef: { system: 'lakebridge', type: 'dependency_graph', id: 'graph-wave-1' },
toolVersion: 'lakebridge@0.14.2',
},
'cookbook:graph',
);
const dependencyGraphId = graph.data.dependencyGraphId;
await ok(
AIRLIFT_ACTION_IDS.dependencyBatchRecord,
ORG,
AGENT,
{
dependencyGraphId,
edges: [
{
fromObjectId: objectIds[2],
toObjectId: objectIds[0],
kind: 'consumer',
confidence: 1,
critical: true,
},
{
fromObjectId: objectIds[2],
toObjectId: objectIds[1],
kind: 'consumer',
confidence: 1,
critical: false,
},
],
},
'cookbook:edges',
);
await ok(
AIRLIFT_ACTION_IDS.dependencyGraphAccept,
ORG,
OPERATOR,
{ dependencyGraphId, expectedEdgeCount: 2 },
'cookbook:graph-accept',
);
await ok(
AIRLIFT_ACTION_IDS.assessmentAccept,
ORG,
OPERATOR,
{
assessmentId,
engagementId,
dependencyGraphId,
expectedObjectCount: objectIds.length,
assumptions: ['A representative production window is available for validation.'],
exclusions: [],
},
'cookbook:assessment-accept',
);
// ── 5. Migration plan: generate → select → freeze ────────────────────────────
// Plan generation produces dependency-aware wave candidates plus economics; a
// human selects the scenario. Freezing requires the engagement scope frozen
// first — the plan digest then pins the delivery contract.
const generated = await ok(
AIRLIFT_ACTION_IDS.planGenerate,
ORG,
OPERATOR,
{
engagementId,
assessmentId,
name: 'Parity-first delivery plan',
scenario: 'parity_first',
targetWorkspaceRef: { system: 'databricks', type: 'workspace', id: 'acme-target' },
maxObjectsPerWave: 3,
economics: {
manualHoursPerComplexityPoint: 8,
assistedHoursPerComplexityPoint: 3,
blendedHourlyCostUsd: 180,
sourceMonthlyRunCostUsd: 25_000,
targetMonthlyRunCostUsd: 18_000,
dualRunMonths: 2,
},
assumptions: ['Unity Catalog prerequisites are completed before deployment.'],
},
'cookbook:plan',
);
const migrationPlanId = generated.data.migrationPlanId;
await ok(AIRLIFT_ACTION_IDS.planSelect, ORG, OPERATOR, { migrationPlanId }, 'cookbook:plan-select');
await ok(
AIRLIFT_ACTION_IDS.engagementFreeze,
ORG,
OPERATOR,
{ engagementId },
'cookbook:engagement-freeze',
);
await ok(AIRLIFT_ACTION_IDS.planFreeze, ORG, OPERATOR, { migrationPlanId }, 'cookbook:plan-freeze');
// ── 6. Wave plan/assign — objects reach `planned` ────────────────────────────
console.log('\n— Wave and conversion factory —');
const wave = await ok(
AIRLIFT_ACTION_IDS.wavePlan,
ORG,
OPERATOR,
{ estateId, name: 'Wave 1 — sales marts', targetWindow: '2026-09-15' },
'cookbook:wave',
);
const waveId = wave.data.waveId;
for (const objectId of objectIds) {
await ok(
AIRLIFT_ACTION_IDS.waveAssign,
ORG,
OPERATOR,
{ waveId, objectId },
`cookbook:assign:${objectId}`,
);
}
// ── 7. Conversion batch + per-object conversions ─────────────────────────────
// The batch groups conversions under one governed unit; the worker drives each
// object through conversion_start / conversion_record. One object fails on
// purpose to open the residue lane.
const batch = await ok(
AIRLIFT_ACTION_IDS.conversionBatchCreate,
ORG,
OPERATOR,
{
engagementId,
assessmentId,
name: 'Wave 1 stored-code batch',
method: 'lakebridge',
toolVersion: 'lakebridge@0.14.2',
objectIds,
},
'cookbook:batch',
);
const conversionBatchId = batch.data.conversionBatchId;
await ok(
AIRLIFT_ACTION_IDS.conversionBatchStart,
ORG,
WORKER,
{ conversionBatchId },
'cookbook:batch-start',
);
const conversionIds = [];
for (const [index, objectId] of objectIds.entries()) {
const start = await ok(
AIRLIFT_ACTION_IDS.conversionStart,
ORG,
WORKER,
{
objectId,
batchId: conversionBatchId,
method: 'lakebridge',
toolVersion: 'lakebridge@0.14.2',
},
`cookbook:cnv-start:${objectId}`,
);
conversionIds.push(start.data.conversionId);
const isResidue = index === 2; // the view fails conversion and becomes residue
await ok(
AIRLIFT_ACTION_IDS.conversionRecord,
ORG,
WORKER,
isResidue
? {
conversionId: start.data.conversionId,
objectId,
outcome: 'failed',
detail: 'Unsupported dynamic SQL construct.',
}
: {
conversionId: start.data.conversionId,
objectId,
outcome: 'converted',
artifactRef: `volumes/target/${objectId}.sql`,
outputDigest: 'e'.repeat(64),
},
`cookbook:cnv-record:${objectId}`,
);
if (!isResidue) {
// Batch completion requires every converted object to carry an immutable
// target artifact whose digest matches the recorded conversion output.
await ok(
AIRLIFT_ACTION_IDS.artifactRegister,
ORG,
AGENT,
{
engagementId,
objectId,
kind: 'target_code',
name: `Converted SQL for ${objectId}`,
artifactRef: {
system: 'databricks',
type: 'volume_object',
id: `target/${objectId}.sql`,
digest: 'e'.repeat(64),
},
digest: 'e'.repeat(64),
mediaType: 'application/sql',
toolVersion: 'lakebridge@0.14.2',
},
`cookbook:artifact:${objectId}`,
);
}
}
// ── 8. Residue lane: create → estimate → assign → resolve → review ───────────
// Residue is the human-remediation lane for what automation could not convert.
// Resolution requires a registered artifact and validation evidence ref; review
// must come from a different natural person than the resolver (separation of
// duties) — demonstrated below in the denials section.
const residueObjectId = objectIds[2];
const residue = await ok(
AIRLIFT_ACTION_IDS.residueCreate,
ORG,
AGENT,
{
engagementId,
objectId: residueObjectId,
category: 'unsupported_construct',
lane: 'human',
summary: 'Rewrite dynamic SQL for Databricks SQL',
requiredSkills: ['T-SQL', 'Databricks SQL'],
commercialAttribution: 'managed_service',
},
'cookbook:residue',
);
const residueId = residue.data.residueId;
await ok(
AIRLIFT_ACTION_IDS.residueEstimate,
ORG,
AGENT,
{ residueId, estimateMinutes: 240, requiredSkills: ['T-SQL', 'Databricks SQL'] },
'cookbook:residue-estimate',
);
await ok(
AIRLIFT_ACTION_IDS.residueAssign,
ORG,
OPERATOR,
{
residueId,
assignedToRef: { system: 'techfabric', type: 'engineer', id: 'migration-sql-specialist' },
workRef: { system: 'tower', type: 'work_item', id: 'work-42' },
},
'cookbook:residue-assign',
);
// The batch can complete once every failed object has an ACTIVE residue case —
// completion reports `completed_with_residue`; remediation continues after it.
await ok(
AIRLIFT_ACTION_IDS.conversionBatchComplete,
ORG,
WORKER,
{ conversionBatchId },
'cookbook:batch-complete',
);
const repairedArtifact = await ok(
AIRLIFT_ACTION_IDS.artifactRegister,
ORG,
OPERATOR,
{
engagementId,
objectId: residueObjectId,
kind: 'target_code',
name: 'Repaired view SQL',
artifactRef: {
system: 'databricks',
type: 'volume_object',
id: 'target/vw_sales_by_region.sql',
},
digest: 'f'.repeat(64),
mediaType: 'application/sql',
toolVersion: 'human-remediation@1',
},
'cookbook:residue-artifact',
);
await ok(
AIRLIFT_ACTION_IDS.residueResolve,
ORG,
OPERATOR,
{
residueId,
resolvedArtifactId: repairedArtifact.data.artifactId,
resolutionEvidenceRef: {
system: 'experiments',
type: 'validation_run',
id: 'validation-residue-1',
digest: 'a'.repeat(64),
},
actualMinutes: 180,
resolutionNotes: 'Replaced unsupported syntax and passed the scenario suite.',
},
'cookbook:residue-resolve',
);
// ── Expected denial: separation of duties on residue review ──────────────────
console.log('\n— Governed denials (first-class outcomes) —');
await denied(
'separation of duties: the resolver cannot review their own residue',
{ stage: 'handler', errorIncludes: 'The resolver cannot independently review the same residue.' },
AIRLIFT_ACTION_IDS.residueReview,
ORG,
OPERATOR,
{ residueId, decision: 'approved', reviewNotes: 'Self review must fail.' },
);
await ok(
AIRLIFT_ACTION_IDS.residueReview,
ORG,
APPROVER,
{ residueId, decision: 'approved', reviewNotes: 'Artifact lineage and evidence reviewed.' },
'cookbook:residue-review',
);
// The failed conversion sent the object to `rework`; the reviewed repair is
// recorded as a fresh governed conversion (outside the completed batch)
// referencing the repaired artifact.
const reconvert = await ok(
AIRLIFT_ACTION_IDS.conversionStart,
ORG,
WORKER,
{
objectId: residueObjectId,
method: 'manual',
toolVersion: 'human-remediation@1',
},
'cookbook:cnv-start:residue-repair',
);
await ok(
AIRLIFT_ACTION_IDS.conversionRecord,
ORG,
WORKER,
{
conversionId: reconvert.data.conversionId,
objectId: residueObjectId,
outcome: 'converted',
artifactRef: 'target/vw_sales_by_region.sql',
outputDigest: 'f'.repeat(64),
},
'cookbook:cnv-record:residue-repair',
);
// ── Expected denial: agents cannot approve waves ─────────────────────────────
// Authorization is fail-closed at entitlement: wave_approve is outside the
// bounded agent action set, so the agent is not entitled to the module at all
// for this action — the denial is the generic entitlement error, not a
// wave-specific message.
await denied(
'agent actor attempting wave_approve (approvals are outside the bounded agent action set)',
{ stage: 'handler', errorIncludes: 'is not enabled for tenant' },
AIRLIFT_ACTION_IDS.waveApprove,
ORG,
AGENT,
{ waveId },
);
// ── 9. Validation evidence, readiness, certification per object ──────────────
// For each object: validation profile → validation run (admitted principal +
// verifier + immutable run refs) → readiness track records → parity certificate
// (honest depth fields) → business acceptance → migration certificate mint.
console.log('\n— Validation, readiness, and certification —');
function honestParityCertificate() {
const now = new Date().toISOString();
return {
// Depth honesty (ADR-0003): the certificate states its own evidence depth and
// must carry the evidence for every depth it claims — policy blocks overclaims.
evidenceDepth: 'sampled_rows',
rowCountSource: 1_204_331,
rowCountTarget: 1_204_331,
aggregateChecksums: { 'sum(amount)': 'a1b2c3', 'count(*)': '1204331' },
sampledRowCount: 10_000,
toolVersion: 'lakebridge@0.14.2',
validationRunRef: VALIDATION_RUN_REF,
sourceSnapshotAt: now,
targetSnapshotAt: now,
};
}
// ── Expected denial: overclaimed parity depth ────────────────────────────────
const overclaiming = honestParityCertificate();
overclaiming.aggregateChecksums = undefined; // claims sampled_rows depth without aggregate evidence
await denied(
'parity certificate claiming a depth without carrying its evidence',
{ stage: 'policy', errorIncludes: 'missing aggregateChecksums' },
AIRLIFT_ACTION_IDS.parityCertify,
ORG,
WORKER,
{ objectId: objectIds[0], certificate: overclaiming },
);
const certifiedObjects = [];
for (const objectId of objectIds) {
const object = runtime.db.objects.get(ORG, objectId);
const profile = defaultValidationProfile(object.objectType);
await ok(
AIRLIFT_ACTION_IDS.validationProfileAssign,
ORG,
OPERATOR,
{
objectId,
profileId: profile.profileId,
profileVersion: profile.version,
reason: 'Assign the default validation profile for the object type.',
},
`cookbook:profile:${objectId}`,
);
// The run is admitted only because the whole tuple — producer, provider, run ref,
// evidence ref and digest, plus the artifact digest and verdict — matches the
// immutable registry entry configured at startup. That is binding, not attestation:
// the manifest establishes no provider provenance, which is sufficient here because
// this Synapse estate is outside the governed hazard profile set.
const run = await ok(
AIRLIFT_ACTION_IDS.validationRunRecord,
ORG,
WORKER,
{
objectId,
provider: 'experiments',
providerRunRef: VALIDATION_RUN_REF,
evidenceRef: VALIDATION_EVIDENCE_REF,
evidenceDigest: VALIDATION_EVIDENCE_DIGEST,
verdict: 'passed',
toolVersion: 'databricks-testkit@0.9.0',
artifactDigest: VALIDATION_ARTIFACT_DIGEST,
sourceWatermark: 'source:v1',
targetSnapshot: 'target:v1',
completedAt: new Date().toISOString(),
},
`cookbook:validation:${objectId}`,
);
// Readiness tracks: admit the validation run as evidence for every required
// track except business acceptance (a human decision, below) and cutover
// readiness (out of scope for a local demo — see the boundary note).
for (const requirement of profile.requirements) {
if (
requirement.applicability === 'required' &&
requirement.track !== 'business_acceptance' &&
requirement.track !== 'cutover_readiness'
) {
await ok(
AIRLIFT_ACTION_IDS.readinessRecord,
ORG,
WORKER,
{
objectId,
track: requirement.track,
validationRunId: run.data.validationRunId,
reason: 'Validation evidence admitted for the track.',
},
`cookbook:readiness:${objectId}:${requirement.track}`,
);
}
}
await ok(
AIRLIFT_ACTION_IDS.parityCertify,
ORG,
WORKER,
{ objectId, certificate: honestParityCertificate() },
`cookbook:parity:${objectId}`,
);
await ok(
AIRLIFT_ACTION_IDS.businessAccept,
ORG,
APPROVER,
{
objectId,
evidenceRef: `decisions/${objectId}`,
evidenceDigest: 'c'.repeat(64),
decision: 'accepted',
reason: 'Business owner accepted parity and validation evidence.',
},
`cookbook:accept:${objectId}`,
);
// Minting binds the certificate to the exact profile and readiness digests the
// caller observed — stale expectations are rejected.
const assignment = runtime.db.profileAssignments.get(ORG, objectId);
const snapshot = readinessSnapshot(runtime.db, ORG, objectId);
const minted = await ok(
AIRLIFT_ACTION_IDS.migrationCertificateMint,
ORG,
WORKER,
{
objectId,
expectedProfileDigest: assignment.profileDigest,
expectedReadinessDigest: snapshot.digest,
},
`cookbook:mint:${objectId}`,
);
certifiedObjects.push({ objectId, certificateId: minted.data.certificateId });
}
// ── 10. Wave approval by a distinct natural person ───────────────────────────
await ok(AIRLIFT_ACTION_IDS.waveApprove, ORG, APPROVER, { waveId }, 'cookbook:wave-approve');
// ── 11. Offline certificate verification ─────────────────────────────────────
// The minted envelope carries an Ed25519 signature over its content digest, so
// it verifies offline with nothing but the public key. This script verifies
// in-process; the printed `fa certificate verify` command is the offline
// follow-up for you to run against the exported files.
console.log('\n— Offline verification —');
const envelope = runtime.db.migrationCertificates.activeForObject(ORG, objectIds[0]).envelope;
const verifier = createEd25519EvidenceVerifier({ [SIGNING_KEY_ID]: publicKeyPem });
if (!(await verifyMigrationCertificateEnvelope(envelope, verifier))) {
console.error('✗ minted certificate failed offline verification');
process.exit(1);
}
console.log('✓ minted migration certificate verifies offline (in-process)');
const outDir = process.argv[2] ?? mkdtempSync(join(tmpdir(), 'airlift-cookbook-'));
mkdirSync(outDir, { recursive: true });
// ── 12. Governed evidence export ─────────────────────────────────────────────
// `evidence_export` assembles the replayable evidence pack for one wave (or one
// object) from the org's event log — assessment, conversions, parity
// certificates, approvals, everything addressed to or about the subject — plus
// the governance revision in force at export time. Unlike every worker-driven
// step above, this action requires a natural person holding
// `airlift:evidence:export` (admin/operator/approver/validator — the cookbook's
// operator is an admin); it is deliberately
// NOT in the trusted-worker action set, so the conversion worker is denied even
// though it drove the conversions the pack now reports on.
console.log('\n— Evidence export —');
await denied(
'system worker invoking evidence_export (natural-person-only; not a trusted-worker action)',
{ stage: 'handler', errorIncludes: 'is not enabled for tenant' },
AIRLIFT_ACTION_IDS.evidenceExport,
ORG,
WORKER,
{ waveId, reason: 'Automated export attempt from the conversion worker.' },
);
const evidenceExport = await ok(
AIRLIFT_ACTION_IDS.evidenceExport,
ORG,
OPERATOR,
{ waveId, reason: 'Assemble the replayable evidence pack for the approved wave.' },
'cookbook:evidence-export',
);
const evidenceReportFile = join(outDir, 'evidence-export.report.json');
const evidenceMetaFile = join(outDir, 'evidence-export.meta.json');
// `reportJson` is the exact string the action digested — write it byte for byte
// so `reportDigest` remains verifiable against the file on disk.
writeFileSync(evidenceReportFile, evidenceExport.data.reportJson);
writeFileSync(
evidenceMetaFile,
`${JSON.stringify(
{
waveId: evidenceExport.data.waveId,
exportKey: evidenceExport.data.exportKey,
reportDigest: evidenceExport.data.reportDigest,
reason: evidenceExport.data.reason,
},
null,
2,
)}\n`,
);
console.log(`✓ evidence pack exported: ${evidenceReportFile}`);
console.log(` exportKey: ${evidenceExport.data.exportKey}`);
console.log(` reportDigest: ${evidenceExport.data.reportDigest}`);
const certificateFile = join(outDir, 'migration-certificate.json');
const keysFile = join(outDir, 'verify-keys.json');
writeFileSync(certificateFile, `${JSON.stringify(envelope, null, 2)}\n`);
writeFileSync(keysFile, `${JSON.stringify({ [SIGNING_KEY_ID]: publicKeyPem }, null, 2)}\n`);
console.log(`\nCertificate envelope: ${certificateFile}`);
console.log(`Verification keys: ${keysFile}`);
console.log(`Verify offline with:\n fa certificate verify ${certificateFile} --keys ${keysFile}`);
// ── Pre-cutover boundary ─────────────────────────────────────────────────────
console.log(`
— Pre-cutover boundary —
This local demo stops at certified objects and an approved wave. It deliberately
does NOT claim: cutover execution or rehearsal, data transfer, workspace job
runs, deployment evidence, or production operational evidence. Those stages
require the durable runtime, real effectors, and independently produced
evidence — a local in-memory walkthrough proving them would be an overclaim,
which is exactly what the governance model exists to prevent.`);
console.log(
`\nDone: ${certifiedObjects.length} objects certified, wave approved, 1 residue reviewed, evidence exported, 5 denials demonstrated.`,
);
if (failures > 0) process.exit(1);Local lifecycle cookbook
A runnable walkthrough of the governed migration lifecycle on the SDK's in-memory runtime — configured authorization, platform bootstrap, planning, conversion, residue, evidence, certification, and offline verification.
Configuration map
Understand local, durable, Databricks, Lakebridge, evidence, signing, and Temporal configuration boundaries.