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');
}
}