Current guidance · concepts baseline 0.2.0-preview.1
Complete lumber UNKNOWN recovery
How do live reconciliation and durable v2 recovery differ in executable code?
Use the selected preview versions for this example. Find your packages, or try the package-free Lumber guided tour.
This runnable companion to the minimal Actions quickstart demonstrates two different verifier-only paths. It uses only package-root imports and a local JSON file owned by the host. The file stands in for both durable lifecycle storage and authoritative inventory; production storage, concurrency, leases, scheduling, access control, encryption, and retention remain host responsibilities.
Path A: same live Actions runtime
Retain the exact UNKNOWN object returned by the protected Actions operation and use exactly runtime.reconcile(initial). This performs verifier-only authoritative read-back with zero provider redispatch, returns a distinct related result, and leaves the frozen original UNKNOWN unchanged. The association is process-local, identity-bound, and one-shot. A clone, spread, reconstruction, serialized value, cross-instance result, terminal result, or already-consumed result has no live authority. A related UNKNOWN may carry the next one-shot association. context.reverify is private and unsupported; it is named here only as an erratum so it is never mistaken for executable guidance.
The example uses the outcome-aware overload with a LiveReconciliationClaimPlan. A live UNKNOWN plan branch can produce only RELATED_LIVE_RECONCILIATION_OBSERVATION_ONLY: historical authority about that exact live observation, not current-state, retry, redispatch, compensation, process-loss, or future-state authority. The one-argument overload remains the proposition-specific, fail-closed compatibility path.
Path B: after process loss
Actions exports a closed version-2 recovery record from its owned UNKNOWN. A later process registers the stable recovery verifier and supplies host-retained authority. acquire checks the accepted source digest; commit compares that digest and either clears a terminal record or atomically installs the next UNKNOWN record and digest. This separate host-operated durable flow does not reconstruct live authority and exposes no provider, dispatch, or retry method.
The current contract preserves accepted Runtime preview.3 DurableRecoveryRecordV2 compatibility, stable verifier binding, source-record digest checks, and host-owned lease/fencing/CAS ownership. Recovery never starts automatically after restart: the host must retain or export the appropriate record material and provide accepted recovery authority. A recovered UNKNOWN can produce only RELATED_RECOVERY_OBSERVATION_ONLY, meaning that exact recovery observation remained unresolved—not that the effect is still unresolved now.
The example supplies a complete RecoveryClaimPlan before recovery. Its COMMITTED, DISPROVED, and UNKNOWN propositions are mutually exclusive alternatives, not simultaneously granted claims. Runtime establishes recovered truth first, mechanically selects the matching proposition, and asks Core to evaluate only that exact proposition. Unused branches never become authority. The older single-claim overload remains a fail-closed, outcome-specific compatibility path. See safe claims.
Compile and execute
Save the program as lumber-recovery.mts beside the three accepted tarballs. Use the exact local package.json, tsconfig.json, TypeScript 5.8.3, and @types/node 22.15.30 prerequisites shown in the quickstart, then run the same package-local build:
npm install --save-dev --save-exact --cache ./.npm-cache --ignore-scripts --no-audit --no-fund typescript@5.8.3 @types/node@22.15.30
npm install --save-exact --cache ./.npm-cache --ignore-scripts --no-audit --no-fund ./answerup-core-1.0.0-rc.1.tgz ./answerup-runtime-0.2.0-preview.5.tgz ./answerup-actions-0.2.0-preview.8.tgz
npm run build
node dist/lumber-recovery.mjs live
mkdir -p ./state
node dist/lumber-recovery.mjs create ./state/lumber-recovery.json
node dist/lumber-recovery.mjs recover ./state/lumber-recovery.jsoncreate and recover are separate Node processes. The state-file argument is required for both commands; the example explicitly creates and owns ./state and never selects a global or shared fallback. Expected final output retains the original UNKNOWN, adds a related COMMITTED/PRESENT result, and reports one dispatch, one mutation, and two verifier reads. No provider service or credential is used.
Complete program
import * as fs from 'node:fs';
import {
createProtectedActionRuntime,
defineProtectedAction,
type ActionsRecoveryAuthority,
type ActionsRecoveryCommitPatch,
type ActorRef,
type DeliveryIdentity,
type DurableRecoveryRecordV2,
type HostInvocationIdentity,
type ProtectedActionResult,
type SecurityDomainRef,
type TenantRef,
type TrustedContext,
} from '@answerup/actions';
const command = process.argv[2];
const recordPath = process.argv[3];
const requiredRecordPath = () => {
if (!recordPath) throw new Error('state-file argument required: create <path> | recover <path>');
return recordPath;
};
const configurationDigest = 'a'.repeat(64);
const subject = 'lumber:cedar-8ft-gradeA';
type HostRecord = {
authoritativeQuantity: number;
providerDispatches: number;
mutations: number;
verifierReads: number;
originalUnknown: { kind: string; effect: string } | null;
recoveryRecord: DurableRecoveryRecordV2 | null;
acceptedSourceDigest: string | null;
relatedResult: { kind: string; effect: string } | null;
};
const emptyRecord = (): HostRecord => ({ authoritativeQuantity: 100, providerDispatches: 0, mutations: 0, verifierReads: 0, originalUnknown: null, recoveryRecord: null, acceptedSourceDigest: null, relatedResult: null });
const load = (): HostRecord => fs.existsSync(requiredRecordPath()) ? JSON.parse(fs.readFileSync(requiredRecordPath(), 'utf8')) as HostRecord : emptyRecord();
const save = (value: HostRecord) => fs.writeFileSync(requiredRecordPath(), `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
const context: TrustedContext = {
tenant: { tenantRef: 'tenant:lumber-demo' as TenantRef, securityDomainRef: 'security-domain:lumber-demo' as SecurityDomainRef },
actor: { actorRef: 'actor:buyer-42' as ActorRef, assurance: 'host-session' },
authorization: { scopeRef: 'policy-scope:inventory-reservation', policyRevision: 'policy:7' },
provider: { accountRef: 'inventory:local-fixture' },
delivery: { deliveryId: 'delivery:recovery-1' as DeliveryIdentity, source: 'application' },
hostInvocation: { invocationId: 'invocation:recovery-1' as HostInvocationIdentity },
};
const recoveryVerifier = {
verifierIdentity: 'lumber-recovery-verifier',
capability: { capabilityId: 'lumber-reservation', majorVersion: 1 },
configurationDigest,
async verify() {
const host = load(); host.verifierReads += 1; save(host);
return host.authoritativeQuantity === 80
? { status: 'PRESENT' as const, observationRef: 'inventory:recovery-read' }
: { status: 'INDETERMINATE' as const, observationRef: 'inventory:recovery-read' };
},
};
function action() {
return defineProtectedAction({
name: 'reserve_lumber', version: '1',
validateProposal(value) { return value && typeof value === 'object' && (value as { quantity?: unknown }).quantity === 20 ? { valid: true, input: { quantity: 20 } } : { valid: false, kind: 'INVALID_PROPOSAL', code: 'QUANTITY' }; },
ground(input, trusted) { return { kind: 'GROUNDED', grounded: { subject, quantity: input.quantity }, scope: trusted.tenant.securityDomainRef, subject, requestedEffect: { quantity: input.quantity }, candidates: [{ referent: subject, source: 'authoritative', attributes: { resource: subject }, evidenceRefs: ['catalog:snapshot:12'] }], observationRef: 'catalog:observation:12' }; },
authorize({ context: trusted }) { return trusted.authorization.scopeRef === 'policy-scope:inventory-reservation' ? 'PERMIT' : 'DENY'; },
provider: { adapterIdentity: 'local-inventory-provider', capability: { capabilityId: 'lumber-reservation', majorVersion: 1 }, async dispatch() { const host = load(); host.providerDispatches += 1; host.mutations += 1; host.authoritativeQuantity -= 20; save(host); return { kind: 'UNCERTAIN' }; } },
verifier: { async verify() { const host = load(); host.verifierReads += 1; save(host); return { kind: 'INDETERMINATE', observationRef: 'inventory:initial-read' }; } },
recoveryVerifier,
initialExecutionClaimPlan: {
propositions: {
COMMITTED: { propositionRef: 'reservation:committed' },
DISPROVED: { propositionRef: 'reservation:did-not-occur' },
UNKNOWN: { propositionRef: 'reservation:initial-observation-unresolved' },
},
},
});
}
const recoveryClaimPlan = {
subjectRef: subject,
attributes: { quantity: 20 },
propositions: {
COMMITTED: { propositionRef: 'reservation:committed' },
DISPROVED: { propositionRef: 'reservation:did-not-occur' },
UNKNOWN: { propositionRef: 'reservation:recovery-observation-unresolved' },
},
} as const;
function authorityFor(source: DurableRecoveryRecordV2): ActionsRecoveryAuthority {
return {
async acquire(record) {
const host = load();
if (host.acceptedSourceDigest !== record.recordDigest || record.recordDigest !== source.recordDigest) return null;
return { accepted: true, durableRevision: record.reconciliation.revision, durableSequence: record.reconciliation.sequence, acceptedLedgerRef: record.acceptedLedgerRef, proofDigest: 'host-proof:lumber', sourceRecordDigest: host.acceptedSourceDigest };
},
async commit(_record, proof, patch: ActionsRecoveryCommitPatch) {
const host = load();
if (host.acceptedSourceDigest !== proof.sourceRecordDigest || patch.sourceRecordDigest !== proof.sourceRecordDigest) return false;
if (patch.status === 'UNKNOWN') { host.recoveryRecord = patch.recoveryRecord; host.acceptedSourceDigest = patch.nextSourceRecordDigest; }
else { host.recoveryRecord = null; host.acceptedSourceDigest = null; }
host.relatedResult = { kind: patch.status, effect: patch.status === 'COMMITTED' ? 'PRESENT' : patch.status === 'DISPROVED' ? 'ABSENT_FINAL' : 'UNKNOWN' };
save(host); return true;
},
async release() {},
};
}
async function createUnknown() {
save(emptyRecord());
const runtime = createProtectedActionRuntime(); runtime.register(action());
const initial = await runtime.run({ action: 'reserve_lumber', input: { quantity: 20 }, context });
if (initial.kind !== 'UNKNOWN') throw new Error('expected UNKNOWN');
const recoveryRecord = runtime.exportRecoveryRecord(initial, { durableRevision: 1, durableSequence: 1, acceptedLedgerRef: 'ledger:lumber-1', configurationDigest });
const host = load();
host.originalUnknown = { kind: initial.kind, effect: initial.effectTruth.status };
host.recoveryRecord = recoveryRecord; host.acceptedSourceDigest = recoveryRecord.recordDigest; save(host);
console.log(JSON.stringify({ phase: 'created', original: host.originalUnknown, dispatches: host.providerDispatches, mutations: host.mutations }));
}
async function recoverAfterProcessLoss() {
const host = load(); if (!host.recoveryRecord) throw new Error('missing host recovery record');
const source = host.recoveryRecord;
const runtime = createProtectedActionRuntime(); runtime.register(action());
const recovery = await runtime.rehydrateRecovery({ record: source, authority: authorityFor(source), claimPlan: recoveryClaimPlan });
if ('provider' in recovery || 'dispatch' in recovery) throw new Error('recovery must remain verifier-only');
const resolved = await recovery.reconcile();
const after = load();
if (resolved.result.kind !== 'COMMITTED' || after.providerDispatches !== 1 || after.mutations !== 1 || after.originalUnknown?.kind !== 'UNKNOWN') throw new Error('durable recovery invariant failed');
console.log(JSON.stringify({ phase: 'recovered', original: after.originalUnknown, related: after.relatedResult, dispatches: after.providerDispatches, mutations: after.mutations, verifierReads: after.verifierReads }));
}
// Live Actions authority belongs only to the exact UNKNOWN object in this process.
async function liveActionsReconciliation(): Promise<ProtectedActionResult | null> {
let reads = 0; let dispatches = 0;
const runtime = createProtectedActionRuntime();
runtime.register(defineProtectedAction({ name: 'live_lumber', version: '1', validateProposal: () => ({ valid: true, input: { quantity: 20 } }), ground: async () => ({ kind: 'GROUNDED', grounded: { subject, quantity: 20 }, scope: context.tenant.securityDomainRef, subject, requestedEffect: { quantity: 20 }, candidates: [{ referent: subject, source: 'authoritative', attributes: { resource: subject }, evidenceRefs: ['catalog:snapshot:live'] }], observationRef: 'catalog:observation:live' }), authorize: async () => 'PERMIT' as const, provider: { dispatch: async () => { dispatches += 1; return { kind: 'UNCERTAIN' }; } }, verifier: { verify: async () => ++reads === 1 ? { kind: 'INDETERMINATE', observationRef: 'inventory:live-read:1' } : { kind: 'PRESENT', observationRef: 'inventory:live-read:2' } } }));
const initial = await runtime.run({ action: 'live_lumber', input: {}, context });
if (initial.kind !== 'UNKNOWN') throw new Error('expected live UNKNOWN');
const before = JSON.stringify(initial);
const related = await runtime.reconcile(initial, { claimPlan: {
propositions: {
COMMITTED: { propositionRef: 'live-reservation:committed' },
DISPROVED: { propositionRef: 'live-reservation:did-not-occur' },
UNKNOWN: { propositionRef: 'live-reservation:observation-unresolved' },
},
} });
if (related === initial || related.kind !== 'COMMITTED' || JSON.stringify(initial) !== before || dispatches !== 1) throw new Error('live reconciliation invariant failed');
return null;
}
if (command === 'create') await createUnknown();
else if (command === 'recover') await recoverAfterProcessLoss();
else if (command === 'live') { await liveActionsReconciliation(); console.log(JSON.stringify({ phase: 'live', original: 'UNKNOWN', related: 'COMMITTED', redispatches: 0 })); }
else throw new Error('use: live | create <state-file> | recover <state-file>');Boundary
This is a deterministic, single-writer demonstration—not automatic persistence, automatic recovery scheduling, hosted execution, provider integration, or an exactly-once guarantee. The host must retain the authoritative lifecycle record, accepted source digest, CAS authority, stable verifier binding, and relationship between the immutable original and every later result.