← Integration Examples

n8n / local synthetic business API

Keep the workflow. Govern the action.

An existing HTTP workflow updates a record label. n8n remains the orchestrator; a developer-owned Node.js service uses real AnswerUp Actions, Runtime and Core to govern execution, independent verification and supported claims.

Tested with n8n 2.0.3, Node.js 22.23.2 and npm 10.9.8. Both workflow JSON files were imported and executed through the actual n8n CLI engine.

Open workflows and complete application source →

Before Core and with Core

Before: n8n → HTTP Request → local synthetic business-system API.

With Core: n8n → HTTP Request → developer-owned Node.js service running AnswerUp → the same local synthetic API. There is no AnswerUp-hosted executor or paid model call.

The action node changes from POST :4319/direct-update to POST :4318/actions/update and adds a public demo identity header. It keeps the same requestId, recordId and label body shape. The excerpts below are read directly from the downloadable workflow files.

  • = Existing
  • ~ Modified
  • + Added
SettingBefore CoreWith Core
Node typen8n-nodes-base.httpRequestn8n-nodes-base.httpRequest
HTTP methodPOSTPOST
Destinationhttp://127.0.0.1:4319/direct-updatehttp://127.0.0.1:4318/actions/update
Timeout (ms)1000010000
Request body expression={{ JSON.stringify({requestId:'direct-' + $execution.id, recordId:'record-direct', label:'Ready'}) }}={{ JSON.stringify({requestId:'n8n-' + $execution.id + '-success', recordId:'record-success', label:'Ready'}) }}
Demo identity headerNot configured{"parameters":[{"name":"Authorization","value":"Bearer demo-operator"}]}

The body shape stays the same; these fixture expressions use different request and record IDs, so the body row is marked modified. The added identity header is a public synthetic demo value. Full accepted node JSON remains available below.

Before: actual direct HTTP node
{
  "type": "n8n-nodes-base.httpRequest",
  "typeVersion": 4.2,
  "parameters": {
    "method": "POST",
    "url": "http://127.0.0.1:4319/direct-update",
    "options": {
      "timeout": 10000
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify({requestId:'direct-' + $execution.id, recordId:'record-direct', label:'Ready'}) }}"
  }
}
With Core: actual governed HTTP node
{
  "type": "n8n-nodes-base.httpRequest",
  "typeVersion": 4.2,
  "parameters": {
    "method": "POST",
    "url": "http://127.0.0.1:4318/actions/update",
    "options": {
      "timeout": 10000
    },
    "sendHeaders": true,
    "headerParameters": {
      "parameters": [
        {
          "name": "Authorization",
          "value": "Bearer demo-operator"
        }
      ]
    },
    "sendBody": true,
    "specifyBody": "json",
    "jsonBody": "={{ JSON.stringify({requestId:'n8n-' + $execution.id + '-success', recordId:'record-success', label:'Ready'}) }}"
  }
}

n8n consumes the service's bounded message and claim permission. When an effect is UNKNOWN, it requests POST /actions/:requestId/reconcile instead of resending the write. The 14-node governed demonstration also includes fault controls, replay checks and evidence reads; those are local test fixtures.

The supplied workflows derive a new synthetic request ID from $execution.id per run. For an existing business workflow, preserve an upstream durable operation ID across retries; a new execution ID must not silently become a retry of an uncertain action.

What changes in the service

The host registers a protected action, supplies trusted identity and authorization, calls the actual HTTP writer, and independently reads the exact historical effect. It saves admission before dispatch and joins returned operation, execution, effect and attempt identities before rendering a permitted claim.

Real SDK run and UNKNOWN export
      const result = await runtime.run({ action: ACTION, input, context: trustedContext(requestId, actor) });
      if (result.providerDispatchOccurred && !joined(result, entry)) throw fail('RESULT_BINDING_MISMATCH', 500);
      owned.set(result, 'initial');
      if (result.kind === 'UNKNOWN') {
        entry.recovery = runtime.exportRecoveryRecord(result, { durableRevision: 1, durableSequence: 1,
          acceptedLedgerRef: `n8n-ledger:${requestId}`, configurationDigest });
        entry.acceptedDigest = entry.recovery.recordDigest;
      }
Accepted-source, verifier-only recovery
      const capability = await runtime.rehydrateRecovery({ record: source, authority, claimPlan: entry.claimPlan });
      if ('dispatch' in capability || 'provider' in capability) throw fail('RECOVERY_CAPABILITY_INVALID', 500);
      const recovered = await capability.reconcile(); // This capability can only verify, never dispatch.

Core supplies the governed execution and evidence contracts. The host still supplies the authorization policy, provider adapter, authoritative verifier, durable storage, local writer ownership, recovery acquire/commit/release and vetted wording. n8n supplies workflow ordering and recovery scheduling.

Actual SDK references: defineProtectedAction, createProtectedActionRuntime, ProtectedActionRuntime and the Generic HTTP reference. Read provider/verifier responsibilities and UNKNOWN reconciliation before adapting the example.

Verified effects and supported claims

Actual n8n execution14 / 14 nodesCLI import and execution
Focused service checks3 / 3 testsReal packages and HTTP
Recovery redispatches0Verifier-only readback

The recorded sequence is COMMITTED → UNKNOWN → UNKNOWN → COMMITTED: a verified update; a committed write with a lost response and unavailable readback; an inconclusive recovery; then authoritative readback of the original effect. Across the direct and governed workflows there were three writes/mutations and four effect reads. The lost-response operation was written once.

A lost response can remain UNKNOWN when independent evidence is unavailable; loss alone does not force UNKNOWN if readback already proves the effect. Recovery retains the original effect identity and never blindly redispatches.

record_updated is supported only for the verified historical update with matching claim permission. email_sent, another record, current state after later changes and whole-workflow completion are unsupported. A separate real-SDK negative test established COMMITTED/PRESENT truth while withholding a wrong-subject claim. Effect truth alone does not authorize a customer statement.

Actual effect and claim gate
    const committed = result.kind === 'COMMITTED' && result.effectTruth?.status === 'PRESENT'
      && result.effectTruth.effectIdentity === binding.effectIdentity
      && claims?.decision === 'PERMITTED' && claims.claimType === 'EFFECT_COMMITTED'
      && claims.effectIdentity === binding.effectIdentity && claims.executionIdentity === binding.executionIdentity
      && (claims.subjectRef == null || claims.subjectRef === entry.claimPlan.subjectRef)
      && claims.scope === 'historical_effect' && claims.safeClaims.some(c => c.claimType === 'EFFECT_COMMITTED')
      && digest(entry.claimPlan) === digest(claimPlanFor(entry.input));

See safe claims and the sanitized verification record. It identifies the accepted source commit, exact workflow/source hashes, separate test layers and retained evidence digests. Full host state and private execution records are excluded.

Unverified: interactive-editor execution, arbitrary-crash recovery, distributed recovery and production provider behavior. The business-system API, data, identities and fault controls are local synthetic fixtures. Graceful restart after an accepted export was tested; interrupted admission or leases require operator review. This is not a general exactly-once or production-readiness guarantee.

Inspect results, logs and Trace

A successful n8n execution confirms workflow processing; an HTTP node can still return an UNKNOWN action result. Core classifies the independent verifier's evidence for the historical effect. The host also checks claim permission and the saved binding before returning a bounded confirmation.

The downloaded companion has no automatic result logger or Trace exporter. The README below documents optional observation hooks at the live initial and recovery result boundaries, readable .log and structured .jsonl output, and capture/import using privately supplied Trace Local preview.7. Logs and Trace help explain supplied evidence; they do not establish new effect truth or recovery authority.

The README also covers local n8n owner setup, the intended editor import/manual-execution path, CLI warnings and historical dispatch flags. Editor execution remains unverified. A recovered result's providerDispatchOccurred: true describes the original attempted effect; compare actual provider writes and verifier reads to check for redispatch.

Exact selected packages

Independently accepted integration commit: cf5c3977e061d59558890a367fe4807110adbe3e. The application imports the public @answerup/actions API; its selected Runtime and Core dependencies execute underneath it.

  • answerup-actions-0.2.0-preview.8.tgz
    SHA-256: 2f0023f4ada2086e4d52f52149ac9061c5d1f99d4eda82c22ae88f7b46aebc30
  • answerup-core-1.0.0-rc.1.tgz
    SHA-256: ba20e2b71fd0336505b01c63664c4aade4f023bd77fd9108d773d8bb2546d62b
  • answerup-generic-http-reference-0.2.0-preview.4.tgz
    SHA-256: 7a95340d0cb02dc2c45ddb2f3a9a1914022cc65e8940845a723e9620fc015708
  • answerup-runtime-0.2.0-preview.5.tgz
    SHA-256: 3d1f805f30199c498653a5cdd69d0b9df7bd2563ac79f87bdcb8d2ddda8a9336

These are package identities, not public archive links. Obtain SDK dependencies through the existing approved private-preview handoff described in package access. No package archive or SDK implementation is included here.

Workflows and complete application source

Download before-Core workflow JSON · Download governed workflow JSON

Save the files below with their displayed paths and follow the README to install your authorized dependencies, start the two loopback services and run the n8n CLI commands. The source uses only public SDK calls and synthetic demo bearer values. The Portal displays and serves files; it does not execute the business action.

README.md

Save README.md

# n8n + AnswerUp Core: local synthetic record update

Application source and workflow JSON for independently accepted integration commit `cf5c3977e061d59558890a367fe4807110adbe3e`. The seven `src/*.mjs` files and both workflow files are unchanged accepted bytes. This README, package descriptor and verification summary are sanitized documentation additions. No SDK implementation or dependency archive is supplied.

Tested environment: Linux/WSL2, Node.js 22.23.2, npm 10.9.8, n8n 2.0.3, Manual Trigger 1, HTTP Request 4.2. No external business API, paid model or proprietary credential is required. The API, records, demo bearer values and fault controls are local synthetic fixtures.

## Save and install

Save `package.json`, this README, `verification.json`, all seven source modules and the two workflows, preserving these paths:

```text
src/action.mjs  src/host.mjs  src/provider.mjs  src/service.mjs
src/http.mjs    src/storage.mjs  src/cli.mjs
workflows/before-core-direct-http.json
workflows/governed-record-update.json
```

Obtain the selected SDK archives through your existing approved private-preview handoff. Package access and authorization are unchanged by this example. Do not expect public npm publication or public SDK archive URLs. Verify these exact SHA-256 values before installation:

```text
ba20e2b71fd0336505b01c63664c4aade4f023bd77fd9108d773d8bb2546d62b  answerup-core-1.0.0-rc.1.tgz
3d1f805f30199c498653a5cdd69d0b9df7bd2563ac79f87bdcb8d2ddda8a9336  answerup-runtime-0.2.0-preview.5.tgz
2f0023f4ada2086e4d52f52149ac9061c5d1f99d4eda82c22ae88f7b46aebc30  answerup-actions-0.2.0-preview.8.tgz
```

From your saved example directory, replace `/path/to/approved` with the location of your authorized archives:

```sh
npm install --ignore-scripts /path/to/approved/answerup-core-1.0.0-rc.1.tgz /path/to/approved/answerup-runtime-0.2.0-preview.5.tgz /path/to/approved/answerup-actions-0.2.0-preview.8.tgz
npm install --prefix tools/n8n --save-exact --no-audit --no-fund n8n@2.0.3
```

n8n installation needs public npm access and its native/transitive dependencies. Docker is not required. The source follows the selected Generic HTTP reference 0.2.0-preview.4; its identity is included in `verification.json`, but its private archive is not needed to run these saved application modules.

## Run the tested CLI path

Start `npm run provider` in terminal A and `npm start` in terminal B. They bind only to `127.0.0.1:4319` and `127.0.0.1:4318` and persist synthetic state under `.data/`. Optional service/provider environment: `PORT`, `STATE_FILE`; service only: `PROVIDER_URL=http://127.0.0.1:4319`. Stop gracefully with Ctrl-C. A state file allows one writer; abrupt interruption may require operator review, with no automatic stale-lock takeover.

In terminal C:

```sh
export N8N_USER_FOLDER="$PWD/.n8n"
export N8N_ENCRYPTION_KEY=answerup-local-synthetic-preview-only
export N8N_DIAGNOSTICS_ENABLED=false
export N8N_VERSION_NOTIFICATIONS_ENABLED=false
export N8N_TEMPLATES_ENABLED=false
export N8N_PERSONALIZATION_ENABLED=false
export N8N_COMMUNITY_PACKAGES_ENABLED=false
tools/n8n/node_modules/.bin/n8n import:workflow --input=workflows/before-core-direct-http.json
tools/n8n/node_modules/.bin/n8n execute --id=AUN8nDirect --rawOutput
tools/n8n/node_modules/.bin/n8n import:workflow --input=workflows/governed-record-update.json
tools/n8n/node_modules/.bin/n8n execute --id=AUCoreN8nPreview --rawOutput
```

Both files are inactive manual workflows. The before-Core node posts to `/direct-update`; the governed node posts to `/actions/update` with `Authorization: Bearer demo-operator`. The same body shape is `{requestId,recordId,label}`. No n8n credential object is required. `demo-viewer` is an authenticated but policy-denied local identity.

The governed workflow sets fixtures, makes a verified update, checks replay and supported/unsupported claims, then loses the response to a second update while hiding its authoritative read. Expected sequence: **COMMITTED → UNKNOWN → UNKNOWN → COMMITTED**, with no ordinary completion claim while UNKNOWN. It reveals the original evidence before the final verifier-only recovery. On a fresh direct+governed run: three provider writes/mutations, four effect reads, zero recovery redispatches; the lost-response operation has one write.

n8n 2.0.3's CLI JSON retains an early `status:running` snapshot with `finished:true`; the accepted verification also checked its final SQLite execution rows for successful completion and inspected every node output. The sanitized summary distinguishes those checks. A missing optional Python runner warning did not affect the built-in HTTP/Manual nodes.

## Read the result at the right boundary

| Observation | What it establishes |
| --- | --- |
| n8n node/execution success | The workflow engine completed its nodes and received responses. A successful HTTP node can contain an `UNKNOWN` action result. |
| Provider acknowledgment | The writer's observation, not proof of the business effect. |
| `COMMITTED` / `effectTruth.status: PRESENT` | Core classified independent verifier evidence for the exact historical effect. In this example, the host reads `/effects/:effectIdentity` and checks the original binding. |
| SDK `claims.decision: PERMITTED` plus the host's claim gate | A bounded statement about that effect, with matching identities and saved claim plan. The host returns `claimPermission: PERMITTED` only after those checks; effect truth alone is insufficient. |
| Logs or a Trace import | Retained observations for inspection, not new effect truth, live recovery authority or permission to retry. |

n8n orders HTTP calls and schedules the reconciliation request. The developer-owned service supplies trusted identity, policy, provider/verifier I/O, durable recovery authority and wording; Actions, Runtime and Core supply the governed contracts. Read [provider/verifier responsibilities](/docs/provider-verifier), [safe claims](/docs/safe-claims) and [the Actions API](/docs/api/actions#api-ProtectedActionRuntime).

## Optional readable logs and Trace capture

The downloaded service does **not** automatically write result logs or export Trace JSON. The following are optional observation points for a developer's own copy, not changes already present in the accepted source. Keep the existing admission, binding checks, recovery commit, claim projection and save ordering. A diagnostics failure must not become permission to resend an update.

### Write `.log` and `.jsonl` observations

Save the existing [host-owned diagnostics helper](/examples/optional-file-logging/src/diagnostics.mjs) as `src/diagnostics.mjs`; its [companion README](/examples/optional-file-logging/README.md) explains its schema and privacy choices. Initialize it once in the service process:

```js
import { createFileLogger } from './diagnostics.mjs';
const logger = await createFileLogger('.observations');
```

In `src/host.mjs`, `submit()` receives the live Actions result from `await runtime.run(...)`. After its existing `entry.latest = project(result, entry); await save();` and before returning, an optional host observation is:

```js
await logger.record({
  result,
  phase: 'INITIAL',
  customerConfirmationEmitted: entry.latest.claimPermission === 'PERMITTED',
  providerPostCount: state.counters.providerDispatches,
});
```

For `reconcile()`, use the same call with **`result: recovered.result`** and **`phase: 'RECONCILED'`**, after the existing accepted recovery commit, identity checks, `project(recovered.result, entry)` and save. `capability.reconcile()` returns a wrapper; `recovered.result` is the Actions result. The host's replay branches return a saved response without another SDK run; they are not new result boundaries.

The helper appends the same selected observations to `.observations/execution.log` (readable lines) and `.observations/execution.jsonl` (one structured object per line). It records result kind, lifecycle, effect truth, claim enums, historical dispatch flag and per-run HMAC identity aliases. The confirmation field reports the host's message decision, not external customer delivery. Here `providerPostCount` is the host's **cumulative dispatch-attempt count across both governed actions**; it is not an independently measured mutation count or a per-effect count. Compare it with provider events. Keep raw requests, full results, `/evidence`, `/state`, credentials and provider payloads out of these log files. The two appends are not an atomic recovery store.

### Capture live results for Trace Local

If your approved handoff includes **Trace Local `0.1.0-preview.7`**, unpack its archive; it is not an npm package. Its `trace-capture.mjs` and `TRACE-LOCAL-IMPORT.md` are supplied privately, not by a new public SDK export. Copy the helper next to the host module in your own instrumented copy. At the same two result boundaries, select the live package fields:

```js
import { actionsSnapshot } from './trace-capture.mjs';
// At the initial result boundary in submit():
const initialSnapshot = actionsSnapshot(result, 'INITIAL');
// At each accepted recovery result boundary in reconcile():
const recoverySnapshot = actionsSnapshot(recovered.result, 'RECONCILED');
```

These two calls belong in separate existing scopes. Retain their outputs in a bounded host collector **during execution**, grouped by original effect identity. Saving these helper outputs as JSON lines for later assembly is fine; deriving package snapshots from `.log` lines, n8n responses or saved full-result histories is not equivalent to live capture. Do not pass an Actions result to `runtime.project()`. Actions preview.8 normally returns an empty `answerup-actions-trace/1` envelope; zero records do not mean no dispatch or verification occurred. No `TraceSink.append` implementation or automatic SDK logging is supplied here.

Assemble one file per attempted effect. In this illustration, `snapshotsForThisEffect` is **your retained array**, not an SDK API: one `INITIAL` snapshot and up to three `RECONCILED` snapshots for the same operation/effect/execution. Attempt identities may differ. The null/empty host fields explicitly mean evidence was not retained:

```js
import { mkdir, writeFile } from 'node:fs/promises';
await mkdir('.observations', { recursive: true });
await writeFile('.observations/trace-effect.json', JSON.stringify({
  contract: 'answerup-local-trace/1',
  packages: { core: '1.0.0-rc.1', runtime: '0.2.0-preview.5', actions: '0.2.0-preview.8' },
  runRef: 'local:record-update', // Use your own opaque run reference.
  actionLabel: 'Synthetic record label update',
  results: snapshotsForThisEffect,
  host: {
    admission: null, authorization: null, dispatches: [], verifierReads: [],
    claimPlan: null, claim: null,
  },
}, null, 2), { mode: 0o600 });
```

To inspect the verifier and claim joins, replace those empty host fields only with actual bounded observations, following `TRACE-LOCAL-IMPORT.md`:

- Admission/authorization: the saved host admission and executed policy decision, with opaque references. Provider dispatch: the observed boundary/acknowledgment and original effect/attempt identities, not an inference from a recovered result flag.
- Verifier reads: in `src/action.mjs`, retain each actual `verify()` read after its HTTP check and before returning: `{phase, effectIdentity, status, observationRef, finalityRef}`. Use `INITIAL` or `RECONCILIATION`, the actual `PRESENT` or `INDETERMINATE` decision, and the returned `effect-journal-read:...` reference. Include `finalityRef: null` here; this example does not establish final absence. A counter alone cannot recreate a read observation.
- Claim plan/message: retain the saved plan's committed proposition and matching effect/execution identities, and the host's emitted/withheld message decision. Use the helper's `opaqueRef()` consistently for the free-text proposition in both `host.claimPlan` and `host.claim`; use a redacted wording template, not customer text.

All six host keys must remain present. Missing reports remain null/empty and limit the alignment assessment. Follow the bundled contract's exact nested fields, reference rules, redaction requirements and 64 KiB limit; do not combine the verified and lost-response effects in one history. The [Portal Trace documentation](/docs/trace) explains the evidence boundary.

### Open and inspect the captured file

From the unpacked selected Trace directory:

```sh
cd dist
python3 -m http.server 5191 --bind 127.0.0.1
```

Open `http://127.0.0.1:5191/`, choose **Load evidence JSON** and select your captured file. Compare the displayed SHA-256 with `sha256sum .observations/trace-effect.json` run from the companion directory. Use the execution overview to compare initial UNKNOWN, later reconciliation, final effect truth and final-only claim permission. Select an early result row before opening **Separate · final supplied state**; later PERMITTED is not permission at the earlier UNKNOWN boundary. Expand **Verification detail** / **Technical explanation** and the host-reported reads to inspect joins or missing evidence. The timeline groups supplied evidence rather than reconstructing wall-clock order. An alignment message does not authenticate host reports; the file stays in the local browser. Built-in examples are synthetic and are not your execution.

## Try the graphical editor — execution still unverified

The accepted baseline validated CLI execution. Independent DX-3 also completed the 14-node governed CLI workflow and opened the editor at owner-account setup; it did **not** complete owner setup, import through the editor or execute there. The steps below describe the intended local path, not a newly validated editor result.

1. Keep the provider and companion service running on the same machine/network namespace as n8n: the workflow's `127.0.0.1` URLs refer to that machine. Stop any n8n CLI execution before starting the editor. In a terminal with the same `N8N_USER_FOLDER`, encryption key and other exports above, run:

   ```sh
   N8N_PORT=5683 N8N_LISTEN_ADDRESS=127.0.0.1 tools/n8n/node_modules/.bin/n8n start
   ```

2. Open `http://localhost:5683/`. For a fresh instance, complete n8n's owner signup with email, first/last name and a password of at least eight characters including a number and capital letter. This is a local n8n account, separate from AnswerUp Portal access. See [n8n user-management setup](https://docs.n8n.io/hosting/configuration/user-management-self-hosted/).
3. Open the workflow already imported by CLI, or create a workflow and choose **Import from File** in the workflow menu, selecting `workflows/governed-record-update.json`. See [n8n import instructions](https://docs.n8n.io/workflows/export-import/). Keep the manual workflow inactive; no AnswerUp credential object is required for these synthetic demo bearer values.
4. Inspect the nodes, then use **Execute workflow** for a complete [manual execution](https://docs.n8n.io/workflows/executions/manual-partial-and-production-executions/), without pinned data. Inspect the four result nodes and provider/host evidence. Do not treat step reruns or a new `$execution.id` as recovery of an uncertain business action; reconcile the original request instead. Editor import, canvas execution and node inspection remain unverified for this example.

## CLI messages and historical flags

- **`Could not find workflow` during import:** DX-3 observed an initial webhook-cleanup warning followed by `Successfully imported 1 workflow.` and a later successful execution. Check the final import result and workflow ID; do not assume every occurrence of this message is harmless.
- **Missing Python task-runner environment:** the tested Manual Trigger/HTTP Request workflows used no Python node and completed despite this warning. It does not establish that Python workflows work.
- **Task Broker port 5679 already in use:** an editor and CLI instance can contend for the default broker port. This prevented a DX-3 CLI attempt from starting; stop the competing instance before running the documented CLI path. It is not an SDK result or evidence of a provider write.
- **Raw `status: running` with `finished: true`:** inspect final execution status and node outputs rather than that early CLI snapshot alone. With the documented user folder, the local SQLite database is `.n8n/.n8n/database.sqlite`; the read-only query `SELECT id, status, finished, workflowId FROM execution_entity ORDER BY id DESC LIMIT 5;` shows the terminal rows. Successful n8n status still does not imply a verified business effect.
- **`providerDispatchOccurred: true` on a recovered result:** this preserves the historical dispatch distinction for the original attempted effect. It does not say that reconciliation made another write. Compare original effect/attempt identities, actual provider write events, verifier reads and cumulative counters. In DX-3, the governed workflow made two writes and four independent reads; the uncertain effect had one write and three reads, with zero recovery redispatches. The existing direct-plus-governed baseline totals above include a third, direct write.

## Identity, recovery and claims

Workflow `$execution.id` values identify new synthetic operations per run. In a real workflow, use the original durable business operation ID across retries. The same actor/request/input replays its saved result; changed input under the same request ID conflicts. An UNKNOWN result is never permission to make another write.

Host endpoints: `POST /actions/update`, `POST /actions/:requestId/reconcile` with `{}`, `POST /claims/check` with `{requestId,requestedClaim}` and authenticated `GET /evidence`. The synthetic provider exposes `/records/:recordId`, write `/updates`, authoritative historical `/effects/:effectIdentity`, `/fixtures` and `/state`. Fixture and full-state endpoints are local development tools, not a production API design or a safe production logging template.

To reproduce a graceful restart, stop the service after the `Lost response UNKNOWN` node has completed and persisted its accepted recovery export. Restart `npm start` against the same state file. Reconcile the original request with an empty body. If readback is hidden it remains UNKNOWN. Reveal it using `POST :4319/fixtures` with `{"recordId":"record-lost","visible":true}`, then reconcile again. Recovery reads the original effect; it never redispatches the update. Restart behavior was verified separately from the continuous n8n CLI run.

`record_updated` is permitted only for the exact historical update with matching effect and claim permission. `email_sent`, another record, current state after later changes and whole-workflow completion are unsupported. The accepted negative test established COMMITTED/PRESENT truth while withholding a wrong-subject claim. JSON histories and saved responses are not live SDK authority.

Unverified: **interactive-editor execution, arbitrary-crash recovery, distributed recovery and production provider behavior**. Other n8n versions, general exactly-once guarantees and production readiness are also unestablished. See the Portal's provider/verifier, UNKNOWN reconciliation, safe-claims and Actions API documentation before adapting this local example.
package.json

Save package.json

{
  "name": "answerup-n8n-public-example",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=22 <25"
  },
  "scripts": {
    "provider": "node src/cli.mjs provider",
    "start": "node src/cli.mjs service"
  }
}
workflows/before-core-direct-http.json

Save workflows/before-core-direct-http.json

{
  "id": "AUN8nDirect",
  "name": "Before Core - direct record update",
  "versionId": "72b338ce-9a78-40b4-a141-14563d733f69",
  "active": false,
  "nodes": [
    {
      "id": "manual",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {}
    },
    {
      "id": "http-1",
      "name": "Direct update",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4319/direct-update",
        "options": {
          "timeout": 10000
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({requestId:'direct-' + $execution.id, recordId:'record-direct', label:'Ready'}) }}"
      }
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Direct update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataSuccessExecution": "all",
    "saveDataErrorExecution": "all"
  },
  "pinData": {},
  "tags": []
}
workflows/governed-record-update.json

Save workflows/governed-record-update.json

{
  "id": "AUCoreN8nPreview",
  "name": "AnswerUp Core - governed record update preview",
  "versionId": "bbf7be55-1c18-4be1-a8d2-7a5ac6a26e23",
  "active": false,
  "nodes": [
    {
      "id": "manual",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "parameters": {}
    },
    {
      "id": "http-1",
      "name": "Reset success fixture",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        260,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4319/fixtures",
        "options": {
          "timeout": 10000
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{\"recordId\":\"record-success\",\"visible\":true,\"loseResponse\":false}"
      }
    },
    {
      "id": "http-2",
      "name": "Verified update",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        520,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4318/actions/update",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({requestId:'n8n-' + $execution.id + '-success', recordId:'record-success', label:'Ready'}) }}"
      }
    },
    {
      "id": "http-3",
      "name": "Replay same operation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        780,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4318/actions/update",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({requestId:'n8n-' + $execution.id + '-success', recordId:'record-success', label:'Ready'}) }}"
      }
    },
    {
      "id": "http-4",
      "name": "Supported claim",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1040,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4318/claims/check",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({requestId:'n8n-' + $execution.id + '-success', requestedClaim:'record_updated'}) }}"
      }
    },
    {
      "id": "http-5",
      "name": "Unsupported claim",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1300,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4318/claims/check",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({requestId:'n8n-' + $execution.id + '-success', requestedClaim:'email_sent'}) }}"
      }
    },
    {
      "id": "http-6",
      "name": "Lose response and hide readback",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1560,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4319/fixtures",
        "options": {
          "timeout": 10000
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{\"recordId\":\"record-lost\",\"loseResponse\":true,\"visible\":false}"
      }
    },
    {
      "id": "http-7",
      "name": "Lost response UNKNOWN",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1820,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4318/actions/update",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({requestId:'n8n-' + $execution.id + '-lost', recordId:'record-lost', label:'Reviewed'}) }}"
      }
    },
    {
      "id": "http-8",
      "name": "Inconclusive recovery UNKNOWN",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2080,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ 'http://127.0.0.1:4318/actions/n8n-' + $execution.id + '-lost/reconcile' }}",
        "options": {
          "timeout": 10000
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        }
      }
    },
    {
      "id": "http-9",
      "name": "Reveal authoritative evidence",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2340,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4319/fixtures",
        "options": {
          "timeout": 10000
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{\"recordId\":\"record-lost\",\"visible\":true}"
      }
    },
    {
      "id": "http-10",
      "name": "Read only recovery COMMITTED",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2600,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "={{ 'http://127.0.0.1:4318/actions/n8n-' + $execution.id + '-lost/reconcile' }}",
        "options": {
          "timeout": 10000
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "{}",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        }
      }
    },
    {
      "id": "http-11",
      "name": "Replay recovered operation",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        2860,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "http://127.0.0.1:4318/actions/update",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({requestId:'n8n-' + $execution.id + '-lost', recordId:'record-lost', label:'Reviewed'}) }}"
      }
    },
    {
      "id": "http-12",
      "name": "Provider evidence",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3120,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "http://127.0.0.1:4319/state",
        "options": {
          "timeout": 10000
        }
      }
    },
    {
      "id": "http-13",
      "name": "Host evidence",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3380,
        0
      ],
      "parameters": {
        "method": "GET",
        "url": "http://127.0.0.1:4318/evidence",
        "options": {
          "timeout": 10000
        },
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Authorization",
              "value": "Bearer demo-operator"
            }
          ]
        }
      }
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Reset success fixture",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reset success fixture": {
      "main": [
        [
          {
            "node": "Verified update",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verified update": {
      "main": [
        [
          {
            "node": "Replay same operation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Replay same operation": {
      "main": [
        [
          {
            "node": "Supported claim",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Supported claim": {
      "main": [
        [
          {
            "node": "Unsupported claim",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Unsupported claim": {
      "main": [
        [
          {
            "node": "Lose response and hide readback",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lose response and hide readback": {
      "main": [
        [
          {
            "node": "Lost response UNKNOWN",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Lost response UNKNOWN": {
      "main": [
        [
          {
            "node": "Inconclusive recovery UNKNOWN",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inconclusive recovery UNKNOWN": {
      "main": [
        [
          {
            "node": "Reveal authoritative evidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reveal authoritative evidence": {
      "main": [
        [
          {
            "node": "Read only recovery COMMITTED",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read only recovery COMMITTED": {
      "main": [
        [
          {
            "node": "Replay recovered operation",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Replay recovered operation": {
      "main": [
        [
          {
            "node": "Provider evidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Provider evidence": {
      "main": [
        [
          {
            "node": "Host evidence",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "saveDataSuccessExecution": "all",
    "saveDataErrorExecution": "all"
  },
  "pinData": {},
  "tags": []
}
src/action.mjs

Save src/action.mjs

import { createProtectedActionRuntime, defineProtectedAction } from '@answerup/actions';
import { client } from './http.mjs';
import { digest, clone } from './storage.mjs';
import { identities, validInput } from './provider.mjs';
export const ACTION = 'update_record';
export const propositions = {
  COMMITTED: { propositionRef: 'the requested record label update occurred' },
  DISPROVED: { propositionRef: 'the requested record label update did not occur' },
  UNKNOWN: { propositionRef: 'this read could not establish the record update outcome' },
};
export const subjectFor = input => `record:${input.recordId}`;
export const attributesFor = input => ({ operation: 'update_label', label: input.label });
export const claimPlanFor = input => ({ subjectRef: subjectFor(input), attributes: attributesFor(input), propositions });
const routes = new Map();
const routeKey = effectIdentity => JSON.stringify(['domain:demo', effectIdentity]);
async function stableRecoveryVerify(request) {
  if (request.tenantScope !== 'domain:demo' || request.actionName !== ACTION || request.actionVersion !== '1')
    return { status: 'INDETERMINATE' };
  const route = routes.get(routeKey(request.effectIdentity));
  return route ? route(request.effectIdentity, true, request) : { status: 'INDETERMINATE' };
}
export function trustedContext(requestId, actor) {
  return {
    tenant: { tenantRef: 'tenant:demo', securityDomainRef: 'domain:demo' },
    actor: { actorRef: actor, assurance: 'local-synthetic-identity' },
    authorization: { scopeRef: 'policy:record-update', policyRevision: '1' },
    provider: { accountRef: 'synthetic-records:local' },
    delivery: { deliveryId: requestId, source: 'n8n-or-local-client' },
    hostInvocation: { invocationId: requestId },
  };
}
export function buildRuntime({ state, save, providerURL, claimMode = 'supported' }) {
  const configurationDigest = digest({ action: ACTION, version: '1', providerURL, policy: 'record-update-v1',
    verifier: 'exact-effect-journal-v1', claimMode });
  async function verify(effectIdentity, recovery, request) {
    const binding = state.effects[effectIdentity];
    if (!binding || binding.tenant !== 'tenant:demo'
      || (request && !identities.every(k => request[k] === binding[k]))) return { [recovery ? 'status' : 'kind']: 'INDETERMINATE' };
    state.counters.verifierReads++; if (recovery) state.counters.recoveryReads++; await save();
    let present = false;
    try {
      const response = await client(`${providerURL}/effects/${encodeURIComponent(effectIdentity)}`, { signal: request?.signal });
      const observed = response.data;
      present = response.status === 200 && observed.committed === true && Number.isSafeInteger(observed.version)
        && observed.version > 0 && ['tenant', 'recordId', 'label', ...identities].every(k => observed.binding?.[k] === binding[k]);
    } catch { /* Unavailable independent evidence stays inconclusive. */ }
    return { [recovery ? 'status' : 'kind']: present ? 'PRESENT' : 'INDETERMINATE',
      observationRef: `effect-journal-read:${state.counters.verifierReads}` };
  }
  for (const effectIdentity of Object.keys(state.effects)) routes.set(routeKey(effectIdentity), verify);
  const runtime = createProtectedActionRuntime();
  runtime.register(defineProtectedAction({
    name: ACTION, version: '1',
    validateProposal: input => validInput(input)
      ? { valid: true, input: { recordId: input.recordId, label: input.label } }
      : { valid: false, kind: 'INVALID_PROPOSAL', code: 'RECORD_INPUT' },
    async ground(input, context) {
      const read = await client(`${providerURL}/records/${encodeURIComponent(input.recordId)}`);
      if (read.status !== 200 || read.data.recordId !== input.recordId)
        return { kind: 'UNRESOLVED', clarificationRequired: true, candidates: [] };
      const subject = subjectFor(input);
      return { kind: 'GROUNDED', grounded: { subject }, scope: context.tenant.securityDomainRef, subject,
        requestedEffect: attributesFor(input), candidates: [{ referent: subject, source: 'authoritative',
          attributes: { resource: subject }, evidenceRefs: ['synthetic-record-catalog'] }],
        observationRef: `record-catalog:${read.data.version}` };
    },
    authorize: ({ context }) => context.actor.actorRef === 'actor:operator' ? 'PERMIT' : 'DENY',
    provider: {
      adapterIdentity: 'synthetic-record-http-writer-v1', capability: { capabilityId: 'record-label-update', majorVersion: 1 },
      async dispatch({ input, operationIdentity, executionIdentity, effectIdentity, attemptIdentity }) {
        const binding = { tenant: 'tenant:demo', ...input, operationIdentity, executionIdentity, effectIdentity, attemptIdentity };
        state.effects[effectIdentity] = clone(binding); state.counters.providerDispatches++;
        routes.set(routeKey(effectIdentity), verify); await save();
        try {
          const response = await client(`${providerURL}/updates`, { method: 'POST', body: binding });
          return response.status === 200 ? { kind: 'ACKNOWLEDGED', providerReference: effectIdentity } : { kind: 'UNCERTAIN' };
        } catch {
          state.counters.lostResponses++; await save();
          return { kind: 'UNCERTAIN', reason: 'RESPONSE_UNAVAILABLE' };
        }
      },
    },
    verifier: { verifierIdentity: 'initial-exact-effect-journal-v1', verify: ({ effectIdentity }) => verify(effectIdentity, false) },
    recoveryVerifier: { verifierIdentity: 'recovery-exact-effect-journal-v1',
      capability: { capabilityId: 'record-label-update', majorVersion: 1 }, configurationDigest,
      verify: stableRecoveryVerify },
    // Negative test uses the shipped claims.build API. Core still establishes truth,
    // but must withhold this claim because its subject does not match the effect.
    ...(claimMode === 'unsupported' ? { claims: { build: ({ effectIdentity, executionIdentity }) => ({
      claimType: 'EFFECT_COMMITTED', effectIdentity, executionIdentity, subjectRef: 'wrong:record',
      propositionRef: 'the requested record label update occurred', polarity: 'positive', scope: 'historical_effect',
      attributes: { operation: 'update_label', label: 'Unrelated' },
    }) } } : { initialExecutionClaimPlan: { propositions } }),
  }));
  return { runtime, configurationDigest };
}
src/host.mjs

Save src/host.mjs

import { randomUUID } from 'node:crypto';
import { readJSON, saveJSON, exclusive, serial, clone, digest } from './storage.mjs';
import { fail, localOrigin } from './http.mjs';
import { identities, validID, validInput } from './provider.mjs';
import { ACTION, claimPlanFor, buildRuntime, trustedContext } from './action.mjs';

const neutral = requestId => ({ requestId, status: 'UNKNOWN', claimPermission: 'WITHHELD', safeClaims: [],
  message: 'This check could not establish the update outcome. Check again without resending the update.', needsReconciliation: true });
export function authenticate(header) {
  if (header === 'Bearer demo-operator') return 'actor:operator';
  if (header === 'Bearer demo-viewer') return 'actor:viewer';
  throw fail('LOCAL_DEMO_AUTH_REQUIRED', 401);
}
export async function createHost({ file, providerURL, claimMode = 'supported' }) {
  localOrigin(providerURL);
  const release = await exclusive(file);
  const state = await readJSON(file, { schema: 'n8n-host/1', entries: {}, effects: {},
    counters: { sdkRuns: 0, providerDispatches: 0, lostResponses: 0, verifierReads: 0, recoveryReads: 0, recoveryAcquires: 0, recoveryCommits: 0 } });
  const queue = serial(); const save = () => saveJSON(file, state);
  // Only exact SDK result objects delivered inside this host can reach projection.
  const owned = new WeakMap();
  function joined(result, entry, recovery = false) {
    const binding = state.effects[result.core?.effectIdentity];
    return binding && binding.tenant === 'tenant:demo' && binding.recordId === entry.input.recordId
      && binding.label === entry.input.label && identities.every(k => result.core[k] === binding[k])
      && result.operation?.operationIdentity === binding.operationIdentity
      && (recovery || (result.operation?.actionName === ACTION && result.operation?.actionVersion === '1'));
  }
  function project(result, entry) {
    if (!owned.has(result) || !joined(result, entry, owned.get(result) === 'recovery')) return { ...neutral(entry.requestId), status: result.kind };
    const binding = state.effects[result.core.effectIdentity];
    const claims = result.claims;
    const committed = result.kind === 'COMMITTED' && result.effectTruth?.status === 'PRESENT'
      && result.effectTruth.effectIdentity === binding.effectIdentity
      && claims?.decision === 'PERMITTED' && claims.claimType === 'EFFECT_COMMITTED'
      && claims.effectIdentity === binding.effectIdentity && claims.executionIdentity === binding.executionIdentity
      && (claims.subjectRef == null || claims.subjectRef === entry.claimPlan.subjectRef)
      && claims.scope === 'historical_effect' && claims.safeClaims.some(c => c.claimType === 'EFFECT_COMMITTED')
      && digest(entry.claimPlan) === digest(claimPlanFor(entry.input));
    return { requestId: entry.requestId, status: result.kind, effectTruth: result.effectTruth.status,
      claimPermission: committed ? 'PERMITTED' : 'WITHHELD', safeClaims: committed ? [{ claimType: 'EFFECT_COMMITTED' }] : [],
      observationClaims: clone(claims?.observationClaims ?? []), binding: clone(binding),
      message: committed ? `The label update of ${entry.input.recordId} to ${entry.input.label} was verified.`
        : result.kind === 'UNKNOWN' ? neutral(entry.requestId).message : 'No supported completion claim is available.',
      needsReconciliation: result.kind === 'UNKNOWN' };
  }
  function validate(body) {
    if (!body || typeof body !== 'object' || Array.isArray(body)
      || Object.keys(body).sort().join(',') !== 'label,recordId,requestId'
      || !validID(body.requestId) || !validInput({ recordId: body.recordId, label: body.label })) throw fail('INPUT_INVALID');
  }
  function entryFor(requestId, actor) {
    if (!validID(requestId)) throw fail('REQUEST_ID_INVALID');
    const entry = state.entries[requestId];
    if (!entry) throw fail('REQUEST_NOT_FOUND', 404);
    if (entry.actor !== actor) throw fail('REQUEST_OWNER_MISMATCH', 403);
    return entry;
  }
  async function submit(actor, body) {
    validate(body);
    return queue(async () => {
      const { requestId, ...input } = body;
      if (Object.hasOwn(state.entries, requestId)) {
        const entry = entryFor(requestId, actor);
        if (digest(input) !== digest(entry.input)) throw fail('REQUEST_CONFLICT', 409);
        return clone(entry.latest); // No second SDK run or provider call, including after restart.
      }
      const entry = { requestId, actor, input, claimPlan: claimPlanFor(input), phase: 'ADMITTED',
        history: [], acceptedDigest: null, recovery: null, lease: null, latest: neutral(requestId) };
      state.entries[requestId] = entry; await save(); // Persist admission BEFORE SDK run.
      const { runtime, configurationDigest } = buildRuntime({ state, save, providerURL, claimMode });
      state.counters.sdkRuns++; await save();
      const result = await runtime.run({ action: ACTION, input, context: trustedContext(requestId, actor) });
      if (result.providerDispatchOccurred && !joined(result, entry)) throw fail('RESULT_BINDING_MISMATCH', 500);
      owned.set(result, 'initial');
      if (result.kind === 'UNKNOWN') {
        entry.recovery = runtime.exportRecoveryRecord(result, { durableRevision: 1, durableSequence: 1,
          acceptedLedgerRef: `n8n-ledger:${requestId}`, configurationDigest });
        entry.acceptedDigest = entry.recovery.recordDigest;
      }
      entry.phase = result.kind === 'UNKNOWN' ? 'UNKNOWN' : 'TERMINAL';
      entry.binding = result.core?.effectIdentity ? clone(state.effects[result.core.effectIdentity]) : null;
      entry.history.push({ stage: 'initial', pid: process.pid, result: clone(result) });
      entry.latest = project(result, entry); await save(); return clone(entry.latest);
    });
  }
  async function reconcile(actor, requestId) {
    if (actor !== 'actor:operator') throw fail('OPERATOR_REQUIRED', 403);
    return queue(async () => {
      const entry = entryFor(requestId, actor);
      if (entry.phase === 'TERMINAL') return clone(entry.latest);
      if (entry.phase !== 'UNKNOWN' || !entry.recovery || entry.lease) throw fail('RECOVERY_OPERATOR_REVIEW_REQUIRED', 409);
      const source = clone(entry.recovery); const binding = entry.binding;
      const { runtime, configurationDigest } = buildRuntime({ state, save, providerURL, claimMode });
      if (source.recordDigest !== entry.acceptedDigest || source.configurationDigest !== configurationDigest
        || source.tenantScope !== 'domain:demo' || source.actionName !== ACTION || source.actionVersion !== '1'
        || source.acceptedLedgerRef !== `n8n-ledger:${requestId}`
        || digest(entry.claimPlan) !== digest(claimPlanFor(entry.input))
        || !binding || !identities.every(k => source[k] === binding[k])
        || digest(state.effects[binding.effectIdentity]) !== digest(binding)) throw fail('RECOVERY_BINDING_MISMATCH', 409);
      let committed = false; const proofDigest = digest({ source: source.recordDigest, fence: randomUUID() });
      const authority = {
        async acquire(record) {
          if (entry.lease || record.recordDigest !== entry.acceptedDigest || digest(record) !== digest(source)) return null;
          entry.lease = { proofDigest, sourceRecordDigest: source.recordDigest };
          entry.latest = neutral(requestId); state.counters.recoveryAcquires++; await save();
          return { accepted: true, durableRevision: record.reconciliation.revision,
            durableSequence: record.reconciliation.sequence, acceptedLedgerRef: record.acceptedLedgerRef,
            proofDigest, sourceRecordDigest: source.recordDigest };
        },
        async commit(record, proof, patch) {
          if (entry.lease?.proofDigest !== proofDigest || proof.proofDigest !== proofDigest
            || proof.sourceRecordDigest !== entry.acceptedDigest || record.recordDigest !== entry.acceptedDigest
            || patch.sourceRecordDigest !== entry.acceptedDigest
            || (patch.status === 'UNKNOWN' && patch.nextSourceRecordDigest !== patch.recoveryRecord?.recordDigest)) return false;
          entry.recovery = patch.status === 'UNKNOWN' ? clone(patch.recoveryRecord) : null;
          entry.acceptedDigest = patch.status === 'UNKNOWN' ? patch.nextSourceRecordDigest : null;
          entry.phase = 'RECOVERY_COMMITTED'; entry.latest = neutral(requestId);
          entry.history.push({ stage: 'cas', pid: process.pid, patch: clone(patch) });
          state.counters.recoveryCommits++; await save(); committed = true; return true;
        },
        async release() { if (entry.lease?.proofDigest === proofDigest) { entry.lease = null; await save(); } },
      };
      const capability = await runtime.rehydrateRecovery({ record: source, authority, claimPlan: entry.claimPlan });
      if ('dispatch' in capability || 'provider' in capability) throw fail('RECOVERY_CAPABILITY_INVALID', 500);
      const recovered = await capability.reconcile(); // This capability can only verify, never dispatch.
      // Shipped projectRecovery deliberately returns null actionName/actionVersion.
      // The validated accepted source above supplies that binding; the result must
      // still match every original operation/execution/effect/attempt identity.
      if (!committed || !joined(recovered.result, entry, true)) return neutral(requestId);
      owned.set(recovered.result, 'recovery');
      entry.phase = recovered.result.kind === 'UNKNOWN' ? 'UNKNOWN' : 'TERMINAL';
      entry.history.push({ stage: 'recovery', pid: process.pid, result: clone(recovered.result) });
      entry.latest = project(recovered.result, entry); await save(); return clone(entry.latest);
    });
  }
  function claim(actor, requestId, requestedClaim) {
    const entry = entryFor(requestId, actor);
    const permitted = requestedClaim === 'record_updated' && entry.latest.claimPermission === 'PERMITTED'
      && entry.latest.safeClaims.some(c => c.claimType === 'EFFECT_COMMITTED');
    return { requestId, requestedClaim, permitted,
      message: permitted ? entry.latest.message : 'That claim is not supported by this action evidence.' };
  }
  return { submit, reconcile, claim, save, snapshot: () => clone(state), close: release };
}
src/provider.mjs

Save src/provider.mjs

import { readJSON, saveJSON, exclusive, serial, clone, digest } from './storage.mjs';
import { bodyJSON, reply, listen, fail } from './http.mjs';
export const identities = ['operationIdentity', 'executionIdentity', 'effectIdentity', 'attemptIdentity'];
export const validID = value => typeof value === 'string' && /^[A-Za-z0-9-]{1,80}$/.test(value);
export const validInput = value => value && typeof value === 'object' && !Array.isArray(value)
  && Object.keys(value).sort().join(',') === 'label,recordId'
  && validID(value.recordId) && ['Ready', 'Reviewed'].includes(value.label);

// This API is synthetic, loopback-only and intentionally exposes fixture controls.
export async function createProvider({ file, port = 0 }) {
  const release = await exclusive(file);
  const state = await readJSON(file, { records: Object.fromEntries(
    ['record-success', 'record-lost', 'record-direct'].map(id => [id, { label: 'Draft', version: 0 }])
  ), effects: {}, fixtures: {}, dispatches: 0, mutations: 0, verifierReads: 0, events: [] });
  const queue = serial(); const save = () => saveJSON(file, state);
  const endpoint = await listen((request, response) => queue(async () => {
    const path = new URL(request.url, 'http://local').pathname;
    if (request.method === 'GET' && path === '/health') return reply(response, 200, { ok: true, pid: process.pid });
    if (request.method === 'GET' && path === '/state') return reply(response, 200, state);
    const body = request.method === 'POST' ? await bodyJSON(request) : null;
    if (path === '/fixtures' && body) {
      if (!validID(body.recordId) || !Object.hasOwn(state.records, body.recordId)
        || Object.keys(body).some(k => !['recordId', 'visible', 'loseResponse'].includes(k))
        || ['visible', 'loseResponse'].some(k => k in body && typeof body[k] !== 'boolean')) throw fail('FIXTURE_INVALID');
      state.fixtures[body.recordId] = { ...state.fixtures[body.recordId], ...body };
      await save(); return reply(response, 200, state.fixtures[body.recordId]);
    }
    if (request.method === 'GET' && path.startsWith('/records/')) {
      const recordId = decodeURIComponent(path.slice(9));
      return reply(response, Object.hasOwn(state.records, recordId) ? 200 : 404, { recordId, ...state.records[recordId] });
    }
    if (body && ['/updates', '/direct-update'].includes(path)) {
      let binding = body;
      if (path === '/direct-update') {
        const { requestId, ...input } = body;
        if (!validID(requestId) || !validInput(input)) throw fail('DIRECT_INPUT_INVALID');
        const id = `direct:${requestId}`;
        binding = { ...input, tenant: 'tenant:demo', ...Object.fromEntries(identities.map(k => [k, id])) };
      }
      if (!validInput({ recordId: binding.recordId, label: binding.label })
        || binding.tenant !== 'tenant:demo' || !identities.every(k => typeof binding[k] === 'string' && binding[k].length > 0)) throw fail('BINDING_INVALID');
      if (!Object.hasOwn(state.records, binding.recordId)) throw fail('RECORD_MISSING', 404);
      state.dispatches++;
      const prior = state.effects[binding.effectIdentity];
      if (prior && digest(prior.binding) !== digest(binding)) throw fail('EFFECT_CONFLICT', 409);
      if (!prior) {
        state.mutations++;
        const record = state.records[binding.recordId]; record.label = binding.label; record.version++;
        state.effects[binding.effectIdentity] = { binding: clone(binding), committed: true, version: record.version };
      }
      const lost = state.fixtures[binding.recordId]?.loseResponse === true;
      state.events.push({ type: 'write', pid: process.pid, binding: clone(binding), responseLost: lost, mutation: !prior });
      await save(); // Durable effect journal BEFORE the actual response socket is destroyed.
      if (lost) return request.socket.destroy();
      return reply(response, 200, { acknowledged: true, effectIdentity: binding.effectIdentity });
    }
    if (request.method === 'GET' && path.startsWith('/effects/')) {
      const effectIdentity = decodeURIComponent(path.slice(9));
      state.verifierReads++; state.events.push({ type: 'read', pid: process.pid, effectIdentity }); await save();
      const effect = state.effects[effectIdentity];
      if (!effect || state.fixtures[effect.binding.recordId]?.visible === false)
        return reply(response, 503, { evidence: 'UNAVAILABLE' });
      // Authoritative historical effect journal, not an echo of the write ACK.
      return reply(response, 200, effect);
    }
    throw fail('NOT_FOUND', 404);
  }), port);
  return { ...endpoint, snapshot: () => clone(state), async close() { await endpoint.close(); await release(); } };
}
src/service.mjs

Save src/service.mjs

import { createHost, authenticate } from './host.mjs';
import { bodyJSON, reply, listen, fail } from './http.mjs';
export async function createService(options) {
  const host = await createHost(options);
  const endpoint = await listen(async (request, response) => {
    const path = new URL(request.url, 'http://local').pathname;
    if (request.method === 'GET' && path === '/health') return reply(response, 200, { ok: true, pid: process.pid });
    const actor = authenticate(request.headers.authorization); // Host identity, never caller-supplied SDK context.
    if (request.method === 'GET' && path === '/evidence') return reply(response, 200, host.snapshot());
    if (request.method !== 'POST') throw fail('NOT_FOUND', 404);
    const body = await bodyJSON(request);
    if (path === '/actions/update') return reply(response, 200, await host.submit(actor, body));
    const reconcile = /^\/actions\/([A-Za-z0-9-]+)\/reconcile$/.exec(path);
    if (reconcile) {
      if (Object.keys(body).length) throw fail('EMPTY_BODY_REQUIRED');
      return reply(response, 200, await host.reconcile(actor, reconcile[1]));
    }
    if (path === '/claims/check') {
      if (Object.keys(body).sort().join(',') !== 'requestId,requestedClaim' || typeof body.requestedClaim !== 'string') throw fail('CLAIM_INPUT_INVALID');
      return reply(response, 200, host.claim(actor, body.requestId, body.requestedClaim));
    }
    throw fail('NOT_FOUND', 404);
  }, options.port ?? 0);
  return { ...endpoint, async close() { await endpoint.close(); await host.close(); } };
}
src/http.mjs

Save src/http.mjs

import { createServer } from 'node:http';
export const fail = (code, status = 400) => Object.assign(new Error(code), { code, status });
export async function bodyJSON(request) {
  let bytes = 0; const chunks = [];
  for await (const chunk of request) {
    bytes += chunk.length; if (bytes > 16384) throw fail('BODY_LIMIT', 413);
    chunks.push(chunk);
  }
  return JSON.parse(Buffer.concat(chunks).toString() || '{}');
}
export function reply(response, status, value) {
  response.writeHead(status, { 'content-type': 'application/json' }); response.end(JSON.stringify(value));
}
export function localOrigin(value) {
  if (typeof value !== 'string' || !/^http:\/\/127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(value)
    || Number(value.split(':').at(-1)) > 65535) throw fail('LOOPBACK_ORIGIN_REQUIRED');
  return value;
}
export async function client(url, { method = 'GET', body, headers = {}, signal } = {}) {
  const response = await fetch(url, { method, redirect: 'error',
    headers: { 'content-type': 'application/json', ...headers },
    body: body === undefined ? undefined : JSON.stringify(body),
    signal: signal ?? AbortSignal.timeout(3000) });
  return { status: response.status, data: await response.json() };
}
export async function listen(handler, port = 0) {
  const server = createServer((request, response) => Promise.resolve(handler(request, response)).catch(error => {
    if (!response.headersSent) reply(response, error.status ?? 500, { error: error.code ?? 'REQUEST_UNAVAILABLE' });
    else response.destroy();
  }));
  await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
  return { url: `http://127.0.0.1:${server.address().port}`, async close() {
    server.closeAllConnections(); await new Promise(resolve => server.close(resolve));
  } };
}
src/storage.mjs

Save src/storage.mjs

// Adapted from selected Generic HTTP preview.4 src/storage.mjs.
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises';
import { dirname } from 'node:path';
import { randomUUID, createHash } from 'node:crypto';
export const clone = value => JSON.parse(JSON.stringify(value));
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
export async function readJSON(file, fallback) {
  try { return JSON.parse(await readFile(file, 'utf8')); }
  catch (error) { if (error.code === 'ENOENT' && fallback !== undefined) return clone(fallback); throw error; }
}
export async function saveJSON(file, data) {
  await mkdir(dirname(file), { recursive: true });
  const temp = `${file}.${process.pid}.${randomUUID()}.tmp`;
  const handle = await open(temp, 'wx', 0o600);
  try { await handle.writeFile(JSON.stringify(data, null, 2) + '\n'); await handle.sync(); }
  finally { await handle.close(); }
  await rename(temp, file);
  const directory = await open(dirname(file), 'r');
  try { await directory.sync(); } finally { await directory.close(); }
}
export async function exclusive(file) {
  await mkdir(dirname(file), { recursive: true });
  const handle = await open(`${file}.lock`, 'wx', 0o600);
  await handle.writeFile(JSON.stringify({ pid: process.pid })); await handle.sync();
  return async () => { await handle.close(); await unlink(`${file}.lock`); };
}
export function serial() {
  let tail = Promise.resolve();
  return operation => { const next = tail.then(operation); tail = next.catch(() => {}); return next; };
}
src/cli.mjs

Save src/cli.mjs

import { resolve } from 'node:path';
import { createProvider } from './provider.mjs';
import { createService } from './service.mjs';
const command = process.argv[2];
const port = Number(process.env.PORT ?? (command === 'provider' ? 4319 : 4318));
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid PORT');
const file = resolve(process.env.STATE_FILE ?? `.data/${command}.json`);
const service = command === 'provider' ? await createProvider({ file, port })
  : command === 'service' ? await createService({ file, port, providerURL: process.env.PROVIDER_URL ?? 'http://127.0.0.1:4319' })
    : (() => { throw new Error('Use: node src/cli.mjs provider|service'); })();
console.log(JSON.stringify({ command, url: service.url, pid: process.pid, node: process.version }));
let stopping = false;
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, async () => {
  if (stopping) return; stopping = true; await service.close(); process.exitCode = 0;
});
verification.json

Save verification.json

{
  "sourceCommit": "cf5c3977e061d59558890a367fe4807110adbe3e",
  "independentAcceptance": "PASS for this exact integration commit, attested by owner",
  "verificationScope": "Actual local n8n CLI import and execution; Node.js service and real selected SDK execution tested separately.",
  "testedEnvironment": {
    "node": "22.23.2",
    "npm": "10.9.8",
    "n8n": "2.0.3",
    "manualTrigger": 1,
    "httpRequest": 4.2
  },
  "verifiedAt": "2026-10-20T04:33:05.163Z",
  "executionMode": "cli",
  "workflowExecutions": [
    {
      "workflowId": "AUN8nDirect",
      "finished": true,
      "status": "success",
      "nodesExecuted": 2
    },
    {
      "workflowId": "AUCoreN8nPreview",
      "finished": true,
      "status": "success",
      "nodesExecuted": 14
    }
  ],
  "outcomes": {
    "Verified update": "COMMITTED",
    "Lost response UNKNOWN": "UNKNOWN",
    "Inconclusive recovery UNKNOWN": "UNKNOWN",
    "Read only recovery COMMITTED": "COMMITTED"
  },
  "governedCounters": {
    "sdkRuns": 2,
    "providerDispatches": 2,
    "lostResponses": 1,
    "verifierReads": 4,
    "recoveryReads": 2,
    "recoveryAcquires": 2,
    "recoveryCommits": 2
  },
  "providerWritesAcrossBothWorkflows": 3,
  "providerMutationsAcrossBothWorkflows": 3,
  "providerVerifierReads": 4,
  "providerRedispatchesDuringRecovery": 0,
  "focusedTests": {
    "passed": 3,
    "failed": 0,
    "skipped": 0
  },
  "claimBoundary": {
    "supported": "Historical record label update, only with matching effect and permitted claim.",
    "unsupported": [
      "email_sent",
      "another record",
      "current label after a later update",
      "whole workflow completion"
    ],
    "negativeTest": "Core established COMMITTED/PRESENT while withholding a wrong-subject claim."
  },
  "unverified": [
    "interactive-editor execution",
    "arbitrary-crash recovery",
    "distributed recovery",
    "production provider behavior",
    "other n8n versions"
  ],
  "sourceEvidence": [
    {
      "path": "evidence/n8n-summary.json",
      "sha256": "283f7d64c1d255e2e050e36051f6182ef4b8b5157703fe21307fa4b549d64ce6"
    },
    {
      "path": "evidence/n8n-command-receipts.json",
      "sha256": "9f2e2d5ff7b479510e34e6a9de5fb9ecd1693088c6dec33e4a030629e12b74fb"
    },
    {
      "path": "evidence/focused-tests.tap.txt",
      "sha256": "c9059c9da25d3cc9cffa4e63311fe5c79e2371767dba2c94f099b5c73a272dec"
    },
    {
      "path": "evidence/unsupported-claim.json",
      "sha256": "7d46f2e0212e300a2efc56cc29e61c7049d220dbb52136932ed58c19202cf8c2"
    },
    {
      "path": "evidence/sdk-execution-coverage.json",
      "sha256": "1f737a287234361ce729912e0e8d270ab9ea6dbc8a542993f41d3b4bd350127b"
    },
    {
      "path": "evidence/package-integrity.json",
      "sha256": "098291effce644a53b9254dbac834ec0c808cae27f4f02f1095203b0532ea406"
    }
  ],
  "publicFiles": [
    {
      "path": "src/action.mjs",
      "sha256": "f3851078b0e9fb1bf18cdc9ae03e49f507482f5b63b80b6e36bcf0a7539052d8",
      "bytes": 6318,
      "classification": "public application example; exact accepted bytes"
    },
    {
      "path": "src/cli.mjs",
      "sha256": "22ad00a2fb8f9a947a02c0a97ecf0fbaa9b401aa6cd98d68c9586ba8b81d4457",
      "bytes": 988,
      "classification": "public application example; exact accepted bytes"
    },
    {
      "path": "src/host.mjs",
      "sha256": "2b077475307bb3b9be533e46119a2f30738a57eef80f69bb0edf1cb3c9cf5fc8",
      "bytes": 10227,
      "classification": "public application example; exact accepted bytes"
    },
    {
      "path": "src/http.mjs",
      "sha256": "02c66ebfcf4ffc73f156fa4288c1bc82d21d597226732fd67a77aaf45ec985f1",
      "bytes": 1829,
      "classification": "public application example; exact accepted bytes"
    },
    {
      "path": "src/provider.mjs",
      "sha256": "4f9786df9cde8b596b1888d884cc54bd2689793988f4bbe6ecfe27392c3a9338",
      "bytes": 4695,
      "classification": "public application example; exact accepted bytes"
    },
    {
      "path": "src/service.mjs",
      "sha256": "a3132490650284a747a8d48a2e4dccdbf3988192548dcdba98251decb59a1d5e",
      "bytes": 1562,
      "classification": "public application example; exact accepted bytes"
    },
    {
      "path": "src/storage.mjs",
      "sha256": "db12bb625af223c0fd2f17a037ef06997e7b720dc70beb38429a616d46bcf8ea",
      "bytes": 1580,
      "classification": "public application example; exact accepted bytes"
    },
    {
      "path": "workflows/before-core-direct-http.json",
      "sha256": "7e6cf5f7158a828d824b30020f958425635935a3c782dcb14f80fea0d05458d8",
      "bytes": 1278,
      "classification": "public application example; exact accepted bytes"
    },
    {
      "path": "workflows/governed-record-update.json",
      "sha256": "ade9456527aa96e50e8d73370772635d09a117c89bca3ec81655e333fc3c7c34",
      "bytes": 11751,
      "classification": "public application example; exact accepted bytes"
    }
  ],
  "selectedPackageArchives": [
    {
      "archive": "answerup-actions-0.2.0-preview.8.tgz",
      "sha256": "2f0023f4ada2086e4d52f52149ac9061c5d1f99d4eda82c22ae88f7b46aebc30"
    },
    {
      "archive": "answerup-core-1.0.0-rc.1.tgz",
      "sha256": "ba20e2b71fd0336505b01c63664c4aade4f023bd77fd9108d773d8bb2546d62b"
    },
    {
      "archive": "answerup-generic-http-reference-0.2.0-preview.4.tgz",
      "sha256": "7a95340d0cb02dc2c45ddb2f3a9a1914022cc65e8940845a723e9620fc015708"
    },
    {
      "archive": "answerup-runtime-0.2.0-preview.5.tgz",
      "sha256": "3d1f805f30199c498653a5cdd69d0b9df7bd2563ac79f87bdcb8d2ddda8a9336"
    }
  ],
  "distribution": "Application examples only. SDK archives, SDK implementations, private distribution records and raw host state are excluded. Dependency access remains through the existing approved private-preview handoff."
}