FabricFabricAirlift
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);