Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ published version with a date and open a fresh empty `[Unreleased]` above it.

### Added

- `@relayfile/adapter-core/events` now provides versioned subscription and handler-event contracts with browser-safe validation against existing adapter event catalogs, preserving logical event identity separately from transport delivery IDs.
- `@relayfile/adapter-github` now exports a cursor-resumable pull-index convergence primitive that backfills `headRef` with one GitHub list request per invocation and no per-record metadata, file, or diff fetches.
- `@relayfile/adapter-linear` now materializes `/linear/issues/by-project/<project-id>/<identifier>.json` aliases so project-scoped consumers can avoid mounting the full issue tree. The path mapper and generated `LAYOUT.md` contract expose the new lookup; existing mounts must resync to materialize and discover it.
- `@relayfile/adapter-ramp` now provides read-only Ramp finance materialization with canonical bill, purchase-order, item-receipt, vendor-agreement, transaction, reimbursement, receipt, vendor, transfer, repayment, dimension, and accounting paths; stable indexes and aliases; a generated `LAYOUT.md` contract; Hookdeck-aware inbound declarations; webhook normalization and signature verification; and digest-visible lifecycle classification. Existing consumers must resync to materialize and discover the new canonical paths and layout contract.
Expand Down
163 changes: 163 additions & 0 deletions docs/event-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Event subscriptions and handler input

`@relayfile/adapter-core/events` defines the versioned data exchanged between an
event source and a handler runtime. It supplies browser-safe TypeScript types,
constructors, and parsers. It does not start listeners or flow runs.

Subscriptions belong before execution: an event that does not match creates no
run. A handler can therefore concentrate on work, without treating a filter
mismatch as cancellation or a successful empty run.

## Declare a subscription

```ts
import { defineEventSubscription } from '@relayfile/adapter-core/events';

export const source = defineEventSubscription({
provider: 'linear',
connectionId: 'conn_linear_team',
eventTypes: ['issue.create', 'issue.update'],
pathPrefixes: ['/linear/issues'],
});
```

The constructor adds `schema: 'relayfile.event-subscription/1'` and returns a
validated, immutable snapshot. `parseEventSubscription(unknown)` reads the
serialized form, including its schema discriminator. Unknown providers, event
names, schema versions, or selector fields are rejected rather than ignored.

The v1 selector contract is:

- `provider` and `connectionId` must both match the event exactly. The connection
ID is a host-resolved reference, never a credential or proof of access.
- `eventTypes` is a nonempty, duplicate-free OR list of exact adapter event
names. For Linear, use `issue.create`, not `linear.issue.create` or `issue.*`.
- `pathPrefixes`, when present, is a nonempty, duplicate-free OR list. At least
one affected event path must equal a prefix or lie beneath it at a `/` segment
boundary. `/linear/issues` matches `/linear/issues/ENG-42__issue-42.json`, but
not `/linear/issues-archive/ENG-42__issue-42.json`. `/` includes every path.
- Omitting `pathPrefixes` selects all paths for the specified connection and
event types. An empty list is rejected; it does not mean all paths.

Paths must be concrete absolute Relayfile paths. Globs, unresolved `{templates}`,
traversal, empty segments, and trailing slashes other than `/` are rejected.
Use adapter-owned path helpers for record paths; do not concatenate provider
resource paths. An adapter's published resource roots, such as `/linear/issues`,
can be used as literal subtree selectors.

These are matching semantics for the host to implement. Neither subscription
parsing nor event parsing performs matching.

## Preserve the event delivered to the handler

At an adapter-aware ingestion boundary, construct an event using the existing
logical identity and adapter-owned path mapping:

```ts
import { createAdapterEvent } from '@relayfile/adapter-core/events';
import { linearIssuePath } from '@relayfile/adapter-linear/path-mapper';

const event = createAdapterEvent({
id: 'upstream-logical-event-123',
provider: 'linear',
eventType: 'issue.create',
workspaceId: 'workspace_123',
connectionId: 'conn_linear_team',
deliveryId: 'transport-attempt-456',
occurredAt: '2026-09-14T19:00:00.000Z',
paths: [linearIssuePath('issue-42', 'ENG-42')],
payload: { id: 'issue-42', identifier: 'ENG-42', title: 'Fix sign-in' },
});
```

This example's helper produces `/linear/issues/ENG-42__issue-42.json`. It uses
the canonical issue record, not an alias or a legacy `metadata.json` path. The
provider path helper runs at the adapter boundary; the generic events module
itself imports no provider adapter.

The constructor adds `schema: 'relayfile.adapter-event/1'`.
`parseAdapterEvent(unknown)` validates the serialized envelope. Both preserve
and freeze a JSON snapshot; neither fetches a fresher provider record.

| Field | Contract |
| --- | --- |
| `id` | Existing logical event identity, stable across redelivery. The constructor never mints one. |
| `provider`, `eventType` | Canonical provider and exact event name from the existing trigger catalog. |
| `workspaceId`, `connectionId` | Context the host must verify against its authenticated binding. |
| `deliveryId` | Optional transport attempt identity; separate from logical event identity. |
| `occurredAt` | Canonical UTC ISO timestamp, including milliseconds and `Z`. |
| `paths` | Nonempty, duplicate-free concrete affected paths supplied by adapter-owned mapping. |
| `payload` | Preserved finite, acyclic JSON data; provider-specific payload schemas remain separate. |

These parsers validate shape, catalog names, and JSON data. They do not validate
provider payload semantics, authenticate an event, authorize a workspace or
connection, match a subscription, or deduplicate delivery. A parsed event remains
untrusted until the host completes those checks.

## Runtime obligations before execution

The Cloud or CLI host owns the executable binding: subscription identity and
revision, authenticated workspace, connection, handler identity, and executable
version. Those deployment fields are outside the portable subscription selector.

Before activating a subscription, the host must resolve the connection, verify
its provider and workspace ownership, check required authentication and connection
scope, and confirm that the bound handler can execute. At delivery, it must:

1. Authenticate the ingress and verify the event's workspace and connection
against that binding. Do not trust those IDs merely because parsing succeeded.
2. Resolve the active subscription revision and apply its selectors. No match
means no run. A stale or unauthorized binding must not invoke the handler.
3. Claim the logical event for the subscription durably before dispatch. Dedupe
by subscription identity plus logical event identity, within the verified
workspace/connection boundary; a transport retry must not create another run.
Record the matched subscription revision with that claim so retries execute
the original binding rather than silently switching handlers or selectors.
4. Persist the accepted event snapshot with the run and pass that same snapshot
to the handler on execution and recovery. Keep external-effect idempotency
separate from delivery deduplication.

An explicit replay or subscription replacement needs its own host policy. A new
transport `deliveryId` or a changed timestamp is not permission to duplicate work.

## Reuse adapter metadata

The events module validates against `KNOWN_TRIGGER_CATALOG` from
`@relayfile/adapter-core/triggers`; it introduces no second provider inventory.
Connection setup can also consume the existing `/inbound`, `/scope-keys`, and
`/writeback-paths` catalogs. Canonical resource paths remain adapter-owned.

The scope-key catalog lists supported connection filter **names**. It does not
describe their value types, discover selectable teams or channels, or grant OAuth
permissions. Consumers must use the adapter's connection/configuration contract
and authenticated discovery for those values. Do not infer a complete connection
form or accept arbitrary filter values from a scope-key name alone.

## Cloud and Flows rollout

The existing Cloud agent `onEvent` envelope requires an explicit bridge. Map its
resource data into `payload`, preserve the exact catalog `eventType`, and carry
verified workspace/connection context and adapter-produced paths into this
envelope. Do not copy a Cloud ID blindly: where Cloud falls back to a timestamp
for identity, retries do not have a sufficient stable dedupe key. Preserve the
upstream logical ID, or derive it at ingress under the existing adapter-owned
logical-key policy before constructing this event. Transport identity belongs in
`deliveryId` when available.

This package adds the contract, not that bridge. Flows' existing `.on(source,
handler)` surface still needs an implementation that binds this subscription to
an executable handler. This document does not introduce a new working Flows API
or claim that Cloud event execution is complete.

Release sequence:

1. Merge this adapter-core change, then publish `core` through the repository's
publish workflow. Feature PRs leave package versions unchanged.
2. Update Flows to the published core version and implement the event binding.
A one-off CLI run must accept a supplied event through the same handler path;
live listening additionally requires an authenticated subscription runtime.
3. Wire Cloud delivery to that handler contract, retaining host authentication,
matching, durable deduplication, and the accepted event snapshot.
4. Update the builder to consume published metadata and generate the supported
subscription declaration plus handler function. Verify the authenticated CLI
and Cloud paths before describing the generated result as runnable end to end.
92 changes: 92 additions & 0 deletions docs/verification/event-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Event contract verification

Working tree based on adapter main `0ce581f5`. Package versions are unchanged.

## Repository gate

Command (from repository root, with local test-server access):

```sh
PATH="/tmp/flows-ci-toolchain/node_modules/.bin:$PATH" npm_config_cache=/tmp/relayfile-event-npm-cache npx turbo build typecheck test > /tmp/relayfile-event-gate.log 2>&1
```

Exit code: 0. Full captured output is retained locally at
`/tmp/relayfile-event-gate.log` (16,086 lines). The final output excerpt is:

```text

Tasks: 157 successful, 157 total
Cached: 0 cached, 157 total
Time: 39.36s

```

This includes the new public-export tests, existing adapter tests and catalog
checks. The new tests cover every catalog event, serialized declarations,
immutable snapshots, required provenance, identity preservation, concrete paths,
and finite acyclic JSON. TypeScript compilation also checks provider/event
mismatch examples. No test gate or existing test was modified.

## Packed consumer and browser bundle

Commands:

```sh
mkdir -p /tmp/relayfile-event-pack
PATH="/tmp/flows-ci-toolchain/node_modules/.bin:$PATH" npm pack --workspace @relayfile/adapter-core --ignore-scripts --pack-destination /tmp/relayfile-event-pack --json > /tmp/relayfile-event-pack.json
./node_modules/.bin/esbuild packages/core/src/events/index.ts --bundle --platform=browser --format=esm --outfile=/tmp/relayfile-event-pack/events.browser.mjs
```

Exit code: 0. Captured bundler output:

```text
../../../tmp/relayfile-event-pack/events.browser.mjs 20.5kb

⚡ Done in 14ms
```

The tarball was unpacked as the sole package in a fresh consumer's node_modules:

```sh
mkdir -p /tmp/relayfile-event-consumer/node_modules/@relayfile/adapter-core
tar -xzf /tmp/relayfile-event-pack/relayfile-adapter-core-0.5.24.tgz -C /tmp/relayfile-event-consumer/node_modules/@relayfile/adapter-core --strip-components=1
cp /tmp/relayfile-event-pack/events.browser.mjs /tmp/relayfile-event-consumer/events.browser.mjs
```

Exit code: 0; no output. The consumer script was:

```js
import assert from 'node:assert/strict';
import * as packed from '@relayfile/adapter-core/events';
import * as browser from './events.browser.mjs';

const input = {
id: 'stable-upstream-event', provider: 'linear', eventType: 'issue.create',
workspaceId: 'workspace', connectionId: 'connection',
occurredAt: '2026-09-14T00:00:00.000Z',
paths: ['/linear/issues/ENG-42__issue-42.json'], payload: { title: 'Example' },
};
const selector = { provider: 'linear', connectionId: 'connection', eventTypes: ['issue.create'] };
assert.deepEqual(packed.createAdapterEvent(input), browser.createAdapterEvent(input));
assert.deepEqual(packed.defineEventSubscription(selector), browser.defineEventSubscription(selector));
assert.equal(packed.parseAdapterEvent(JSON.parse(JSON.stringify(packed.createAdapterEvent(input)))).id, input.id);
assert.throws(() => packed.parseEventSubscription({ ...packed.defineEventSubscription(selector), labels: ['ready'] }));
console.log('Packed package export and browser bundle agree; serialized identity preserved; unsupported selectors rejected.');
```

Command:

```sh
node /tmp/relayfile-event-consumer/check.mjs
```

Captured output:

```text
Packed package export and browser bundle agree; serialized identity preserved; unsupported selectors rejected.
```

Exit code: 0. The bundled module was executed under Node for comparison; this is
not a browser UI test. No credentials, live provider events, subscription runtime,
Cloud deployment, or authored Flows handler bridge were exercised. Those consumer
changes follow the adapter-core release as described in `docs/event-subscriptions.md`.
23 changes: 23 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,29 @@ const adapter = new SchemaAdapter({
});
```

## Event subscriptions

`@relayfile/adapter-core/events` exports versioned subscription declarations and
handler event envelopes with browser-safe validation:

```ts
import { defineEventSubscription } from '@relayfile/adapter-core/events';

const source = defineEventSubscription({
provider: 'linear',
connectionId: 'conn_linear_team',
eventTypes: ['issue.create', 'issue.update'],
pathPrefixes: ['/linear/issues'],
});
```

Event names come from the existing trigger catalog. `createAdapterEvent` and
`parseAdapterEvent` preserve the upstream logical event ID and payload; parsing
does not authenticate, match, or deduplicate. The host performs those checks
before invoking a handler, so unmatched events create no run. The Cloud/Flows
execution bridge is a separate consumer change. See the
[contract and rollout](../../docs/event-subscriptions.md).

## What It Generates

- `adapter.generated.ts`: static mapping logic for path resolution and writeback matching
Expand Down
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
"import": "./dist/src/inbound/index.js",
"default": "./dist/src/inbound/index.js"
},
"./events": {
"types": "./dist/src/events/index.d.ts",
"import": "./dist/src/events/index.js",
"default": "./dist/src/events/index.js"
},
"./package.json": "./package.json"
},
"bin": {
Expand Down
Loading
Loading