Skip to content

Reference source · 0.1.0-dev.11

migration.ts

src/migration.ts · Download original file

import { RESOURCE_TYPES } from './resource-profile.ts';
import type { ReferenceHost } from './host.ts';
import type { Credential, CredentialBody } from './identity.ts';
import type { Archive, ArchiveBody } from './archive.ts';
import { verifyArchive } from './archive.ts';
import { DirectoryTrust, patientAddress } from './discovery.ts';
import { SCOPES } from './authorization.ts';
import { exact, id, integer, jsonBytes, fingerprint, newId, requireThat, ProtocolError } from './core.ts';
import { signMigration, verifiedPlan, verifiedReceipt, proofDigest, verifyHandoff, secureEndpoint } from './migration-proof.ts';
import type { MigrationProof, TransferPlan, TransferReceipt, Handoff } from './migration-proof.ts';

type Outgoing = { plan: MigrationProof; approver: string; state: 'copying' | 'frozen' | 'released' | 'completed' | 'aborted'; final?: Archive; ready?: MigrationProof; release?: MigrationProof; activation?: MigrationProof; abort?: MigrationProof };
type Incoming = { plan: MigrationProof; approver: string; state: 'admitted' | 'staged' | 'ready' | 'active' | 'aborted'; baseline?: Archive; final?: Archive; ready?: MigrationProof; release?: MigrationProof; activation?: MigrationProof; abort?: MigrationProof };
export class HostMigration {
  host: ReferenceHost;
  constructor(host: ReferenceHost) {
    this.host = host;
    host.db.exec(`CREATE TABLE IF NOT EXISTS record_custody (patient TEXT PRIMARY KEY, state TEXT NOT NULL, generation INTEGER NOT NULL, transfer_id TEXT);
      CREATE TABLE IF NOT EXISTS migration_outgoing (id TEXT PRIMARY KEY, patient TEXT NOT NULL, body TEXT NOT NULL);
      CREATE TABLE IF NOT EXISTS migration_incoming (id TEXT PRIMARY KEY, patient TEXT NOT NULL, body TEXT NOT NULL);`);
  }
  custody(patient: string): { state: string; generation: number; transfer_id: string | null } {
    return (this.host.db.prepare('SELECT state,generation,transfer_id FROM record_custody WHERE patient=?').get(patient) as any) ?? { state: 'active', generation: 1, transfer_id: null };
  }
  assertReadable(patient: string): void {
    const state = this.custody(patient).state;
    requireThat(state !== 'moved', 'record_moved'); requireThat(['active','frozen'].includes(state), 'migration_not_active');
  }
  assertWritable(patient: string): void {
    this.assertReadable(patient); requireThat(this.custody(patient).state !== 'frozen', 'migration_fenced');
  }
  setCustody(patient: string, state: string, generation: number, transferId: string | null): void {
    this.host.db.prepare('INSERT INTO record_custody VALUES (?, ?, ?, ?) ON CONFLICT(patient) DO UPDATE SET state=excluded.state,generation=excluded.generation,transfer_id=excluded.transfer_id').run(patient, state, generation, transferId);
  }
  outgoing(transferId: string): Outgoing {
    id(transferId); const row = this.host.db.prepare('SELECT body FROM migration_outgoing WHERE id=?').get(transferId) as any; requireThat(row, 'unknown_transfer'); return JSON.parse(row.body);
  }
  incoming(transferId: string): Incoming {
    id(transferId); const row = this.host.db.prepare('SELECT body FROM migration_incoming WHERE id=?').get(transferId) as any; requireThat(row, 'transfer_not_admitted'); return JSON.parse(row.body);
  }
  save(side: 'outgoing' | 'incoming', state: Outgoing | Incoming, plan: TransferPlan): void {
    this.host.db.prepare(`INSERT INTO migration_${side} VALUES (?, ?, ?) ON CONFLICT(id) DO UPDATE SET body=excluded.body`).run(plan.transfer_id, plan.patient, JSON.stringify(state));
  }
  management(patient: string, approver: string, now: number): void { this.host.authorization.approver({ patient, scopes: [SCOPES.recordTransfer] }, approver, now); }
  local(plan: TransferPlan, side: 'source' | 'destination'): void {
    const identity = this.host.identity.public;
    requireThat(identity.subject === plan[side] && identity.signingKid === plan[`${side}_signing_kid`] && identity.encryptionKid === plan[`${side}_encryption_kid`], 'wrong_transfer_host');
  }
  peer(plan: TransferPlan, caller: CredentialBody, side: 'source' | 'destination'): void {
    requireThat(caller.kind === 'host' && caller.subject === plan[side] && caller.signingKid === plan[`${side}_signing_kid`] && caller.encryptionKid === plan[`${side}_encryption_kid`], 'wrong_transfer_host');
  }
  // Trusted owner integration; each Host authenticates its own owner/representative independently.
  begin(patient: string, destinationCredential: Credential, endpoint: string, current: Record<string, any>, directory: DirectoryTrust, authenticatedApprover: string, now: number, transferId = newId()): MigrationProof {
    id(patient); id(transferId); secureEndpoint(endpoint); this.management(patient, authenticatedApprover, now);
    const located = directory.validate(current, current.subject, now);
    requireThat(located.host === this.host.identity.public.subject && located.record_id === patient && located.identity.signingKid === this.host.identity.public.signingKid && located.identity.encryptionKid === this.host.identity.public.encryptionKid, 'migration_context_mismatch');
    const destination = this.host.trust.validate(destinationCredential, now, undefined, 'host');
    requireThat(destination.subject !== this.host.identity.public.subject, 'invalid_transfer_plan');
    return this.host.authorization.transaction(() => {
      const old = this.host.db.prepare('SELECT body FROM migration_outgoing WHERE id=?').get(transferId) as any;
      if (old) {
        const prior = JSON.parse(old.body) as Outgoing, plan = verifiedPlan(prior.plan, this.host.trust, now);
        requireThat(plan.patient === patient && plan.address === located.address && plan.from_generation === located.generation && plan.destination === destination.subject && plan.destination_signing_kid === destination.signingKid && plan.destination_encryption_kid === destination.encryptionKid && plan.destination_endpoint === endpoint && prior.approver === authenticatedApprover && prior.state !== 'aborted', 'transfer_id_conflict'); return prior.plan;
      }
      const custody = this.custody(patient);
      requireThat(custody.state === 'active' && custody.generation === located.generation && !custody.transfer_id, 'migration_busy');
      requireThat(this.host.db.prepare('SELECT id FROM patients WHERE id=?').get(patient), 'not_found');
      const own = this.host.identity.public;
      const plan: TransferPlan = { v: '0.1', kind: 'plan', transfer_id: transferId, patient, address: patientAddress(located.address).address, source: own.subject, destination: destination.subject, destination_endpoint: endpoint,
        source_signing_kid: own.signingKid, source_encryption_kid: own.encryptionKid, destination_signing_kid: destination.signingKid, destination_encryption_kid: destination.encryptionKid,
        from_generation: located.generation, to_generation: located.generation + 1, created_at: now, expires_at: now + 86400 };
      integer(plan.to_generation);
      const signed = signMigration(plan, this.host.identity, this.host.credential, this.host.trust, now);
      this.setCustody(patient, 'active', plan.from_generation, transferId);
      this.save('outgoing', { plan: signed, approver: authenticatedApprover, state: 'copying' }, plan); return signed;
    });
  }
  admit(planProof: MigrationProof, authenticatedApprover: string, now: number): void {
    const plan = verifiedPlan(planProof, this.host.trust, now); this.local(plan, 'destination'); this.management(plan.patient, authenticatedApprover, now);
    requireThat(now < plan.expires_at, 'transfer_expired');
    this.host.authorization.transaction(() => {
      const old = this.host.db.prepare('SELECT body FROM migration_incoming WHERE id=?').get(plan.transfer_id) as any;
      if (old) { const prior = JSON.parse(old.body) as Incoming; requireThat(proofDigest(prior.plan) === proofDigest(planProof) && prior.approver === authenticatedApprover && prior.state !== 'aborted', 'transfer_id_conflict'); return; }
      requireThat(!this.host.db.prepare('SELECT id FROM patients WHERE id=?').get(plan.patient) && !this.host.db.prepare('SELECT patient FROM record_custody WHERE patient=?').get(plan.patient), 'destination_record_exists');
      this.setCustody(plan.patient, 'staged', plan.to_generation, plan.transfer_id);
      this.save('incoming', { plan: planProof, approver: authenticatedApprover, state: 'admitted' }, plan);
    });
  }
  stage(planProof: MigrationProof, archive: Archive, final: boolean, caller: CredentialBody, now: number): Record<string, any> {
    const plan = verifiedPlan(planProof, this.host.trust, now); this.local(plan, 'destination'); this.peer(plan, caller, 'source');
    const body = verifyArchive(archive, this.host.trust, now, plan.patient);
    requireThat(body.source === plan.source && body.created_at >= plan.created_at, 'migration_context_mismatch');
    const source = this.host.trust.validate(archive.source_credential, body.created_at, plan.source, 'host', archive.status_evidence); this.peer(plan, source, 'source');
    return this.host.authorization.transaction(() => {
      const state = this.incoming(plan.transfer_id); requireThat(proofDigest(state.plan) === proofDigest(planProof), 'migration_context_mismatch');
      if (state.state === 'ready' || state.state === 'active') {
        requireThat(final && state.final && fingerprint(jsonBytes(state.final)) === fingerprint(jsonBytes(archive)), 'migration_final_already_staged');
        return { status: 'ok', ready: state.ready };
      }
      requireThat(state.state !== 'aborted' && now < plan.expires_at, 'transfer_expired'); this.management(plan.patient, state.approver, now);
      if (state.baseline) this.checkCatchup(verifyArchive(state.baseline, this.host.trust, now, plan.patient), body);
      if (!final) {
        state.baseline = archive; state.state = 'staged'; this.save('incoming', state, plan); return { status: 'ok', staged: true };
      }
      this.host.clinical.checkImportAdmission(body.resources.filter(e=>RESOURCE_TYPES.includes(e.resource.resourceType)),body.clinical_history??[],now);
      state.final = archive; state.state = 'ready';
      state.ready = this.receipt('ready', state.plan, plan, now, { archive_digest: fingerprint(jsonBytes(archive)) });
      this.save('incoming', state, plan); return { status: 'ok', ready: state.ready };
    });
  }
  checkCatchup(baseline: ArchiveBody, final: ArchiveBody): void {
    requireThat(final.created_at >= baseline.created_at, 'migration_catchup_incomplete');
    for (const prior of baseline.resources) {
      const current = final.resources.find(entry => entry.resource.resourceType === prior.resource.resourceType && entry.resource.id === prior.resource.id);
      requireThat(current && current.version >= prior.version && (current.version !== prior.version || current.digest === prior.digest), 'migration_catchup_incomplete');
    }
    for (const prior of baseline.receipts) {
      const current = final.receipts.find(receipt => receipt.id === prior.id);
      requireThat(current && fingerprint(jsonBytes(current)) === fingerprint(jsonBytes(prior)), 'migration_catchup_incomplete');
    }
  }
  freeze(transferId: string, now: number): Archive {
    return this.host.authorization.transaction(() => {
      const state = this.outgoing(transferId), plan = verifiedPlan(state.plan, this.host.trust, now); this.local(plan, 'source');
      if (state.final && ['frozen','released','completed'].includes(state.state)) return state.final;
      requireThat(state.state === 'copying' && now < plan.expires_at, 'transfer_expired'); this.management(plan.patient, state.approver, now);
      this.setCustody(plan.patient, 'frozen', plan.from_generation, plan.transfer_id);
      state.final = this.host.snapshotArchive(plan.patient, now); state.state = 'frozen'; this.save('outgoing', state, plan); return state.final;
    });
  }
  release(transferId: string, readyProof: MigrationProof, now: number): MigrationProof {
    return this.host.authorization.transaction(() => {
      const state = this.outgoing(transferId), plan = verifiedPlan(state.plan, this.host.trust, now); this.local(plan, 'source');
      if (state.release) { requireThat(state.ready && proofDigest(state.ready) === proofDigest(readyProof), 'migration_context_mismatch'); return state.release; }
      requireThat(state.state === 'frozen' && state.final && now < plan.expires_at, 'migration_not_frozen'); this.management(plan.patient, state.approver, now);
      const ready = verifiedReceipt(readyProof, state.plan, this.host.trust, now, 'ready');
      requireThat(ready.archive_digest === fingerprint(jsonBytes(state.final)), 'migration_archive_mismatch');
      state.ready = readyProof; state.release = this.receipt('release', state.plan, plan, now, { archive_digest: ready.archive_digest, ready_digest: proofDigest(readyProof) }); state.state = 'released';
      this.setCustody(plan.patient, 'moved', plan.to_generation, plan.transfer_id);
      for (const row of this.host.db.prepare('SELECT body FROM grants').all() as any[]) {
        const grant = JSON.parse(row.body); if (grant.patient === plan.patient) { grant.active = false; this.host.authorization.saveGrant(grant); }
      }
      for (const row of this.host.db.prepare('SELECT id,body FROM auth_requests WHERE status=?').all('pending') as any[]) if (JSON.parse(row.body).patient === plan.patient) this.host.authorization.finishPending(row.id, 'denied', 'record_moved');
      this.save('outgoing', state, plan); return state.release;
    });
  }
  activate(transferId: string, releaseProof: MigrationProof, caller: CredentialBody, now: number): MigrationProof {
    return this.host.authorization.transaction(() => {
      const state = this.incoming(transferId), plan = verifiedPlan(state.plan, this.host.trust, now); this.local(plan, 'destination'); this.peer(plan, caller, 'source');
      if (state.activation) { requireThat(state.release && proofDigest(state.release) === proofDigest(releaseProof), 'migration_context_mismatch'); return state.activation; }
      requireThat(state.state === 'ready' && state.final && state.ready, 'migration_not_ready'); this.management(plan.patient, state.approver, now);
      const release = verifiedReceipt(releaseProof, state.plan, this.host.trust, now, 'release');
      requireThat(release.ready_digest === proofDigest(state.ready) && release.archive_digest === fingerprint(jsonBytes(state.final)) && release.created_at < plan.expires_at, 'migration_archive_mismatch');
      const body = verifyArchive(state.final, this.host.trust, now, plan.patient);
      this.host.insertArchive(state.final, body, fingerprint(jsonBytes(state.final)),now);
      this.setCustody(plan.patient, 'active', plan.to_generation, null);
      state.release = releaseProof; state.activation = this.receipt('activation', state.plan, plan, now, { archive_digest: release.archive_digest, release_digest: proofDigest(releaseProof) }); state.state = 'active';
      this.save('incoming', state, plan); return state.activation;
    });
  }
  complete(transferId: string, activation: MigrationProof, now: number): Handoff {
    return this.host.authorization.transaction(() => {
      const state = this.outgoing(transferId), plan = verifiedPlan(state.plan, this.host.trust, now); this.local(plan, 'source');
      requireThat(state.ready && state.release, 'migration_not_released');
      if (state.activation) requireThat(proofDigest(state.activation) === proofDigest(activation), 'migration_context_mismatch');
      const handoff = { plan: state.plan, ready: state.ready, release: state.release, activation }; verifyHandoff(handoff, this.host.trust, now);
      state.activation = activation; state.state = 'completed'; this.save('outgoing', state, plan); return handoff;
    });
  }
  abort(transferId: string, authenticatedApprover: string, now: number): MigrationProof {
    return this.host.authorization.transaction(() => {
      const state = this.outgoing(transferId), plan = verifiedPlan(state.plan, this.host.trust, now); this.local(plan, 'source'); this.management(plan.patient, authenticatedApprover, now);
      requireThat(!state.release, 'migration_irreversible');
      if (state.abort) return state.abort;
      state.abort = this.receipt('abort', state.plan, plan, now, {}); state.state = 'aborted'; delete state.final;
      this.setCustody(plan.patient, 'active', plan.from_generation, null); this.save('outgoing', state, plan); return state.abort;
    });
  }
  discard(transferId: string, abortProof: MigrationProof, caller: CredentialBody, now: number): void {
    this.host.authorization.transaction(() => {
      const state = this.incoming(transferId), plan = verifiedPlan(state.plan, this.host.trust, now); this.local(plan, 'destination'); this.peer(plan, caller, 'source');
      verifiedReceipt(abortProof, state.plan, this.host.trust, now, 'abort'); requireThat(!state.release && state.state !== 'active', 'migration_irreversible');
      state.state = 'aborted'; state.abort = abortProof; delete state.baseline; delete state.final; delete state.ready;
      const custody = this.custody(plan.patient); if (custody.transfer_id === plan.transfer_id) this.host.db.prepare('DELETE FROM record_custody WHERE patient=?').run(plan.patient);
      this.save('incoming', state, plan);
    });
  }
  // A fresh trusted owner decision may replace withdrawn local representative authority,
  // but cannot change the target, plan expiry, final archive or committed custody decision.
  reauthorize(transferId: string, authenticatedApprover: string, side: 'incoming' | 'outgoing', now: number): void {
    requireThat(['incoming','outgoing'].includes(side), 'invalid_request');
    this.host.authorization.transaction(() => {
      const state = side === 'incoming' ? this.incoming(transferId) : this.outgoing(transferId);
      const plan = verifiedPlan(state.plan, this.host.trust, now); this.local(plan, side === 'incoming' ? 'destination' : 'source'); this.management(plan.patient, authenticatedApprover, now);
      requireThat(!['aborted','active','released','completed'].includes(state.state), 'migration_not_pending');
      if (side === 'outgoing') requireThat(now < plan.expires_at, 'transfer_expired');
      state.approver = authenticatedApprover; this.save(side, state, plan);
    });
  }
  receipt(kind: TransferReceipt['kind'], planProof: MigrationProof, plan: TransferPlan, now: number, fields: Partial<TransferReceipt>): MigrationProof {
    return signMigration({ v: '0.1', kind, transfer_id: plan.transfer_id, plan_digest: proofDigest(planProof), created_at: now, ...fields }, this.host.identity, this.host.credential, this.host.trust, now);
  }
  evidence(patient: string): Handoff[] {
    return (this.host.db.prepare('SELECT body FROM migration_incoming WHERE patient=?').all(patient) as any[]).map(row => JSON.parse(row.body) as Incoming).filter(state => state.state === 'active').map(state => ({ plan: state.plan, ready: state.ready!, release: state.release!, activation: state.activation! }));
  }
  dispatch(body: Record<string, any>, caller: CredentialBody, now: number): Record<string, any> {
    if (body.operation === 'migration_stage' || body.operation === 'migration_stage_final') {
      exact(body, ['operation','plan','archive']); return this.stage(body.plan, body.archive, body.operation === 'migration_stage_final', caller, now);
    }
    if (body.operation === 'migration_activate') {
      exact(body, ['operation','transfer_id','release']); return { status: 'ok', activation: this.activate(body.transfer_id, body.release, caller, now) };
    }
    if (body.operation === 'migration_discard') {
      exact(body, ['operation','transfer_id','abort']); this.discard(body.transfer_id, body.abort, caller, now); return { status: 'ok' };
    }
    throw new ProtocolError('unsupported_operation');
  }
}