Skip to content

Commit 6d77137

Browse files
committed
chore: add changeset for the v8 audit remediation
1 parent aedacde commit 6d77137

35 files changed

Lines changed: 1590 additions & 434 deletions

.changeset/v8-audit-remediation.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
"@temporal-contract/contract": major
3+
"@temporal-contract/client": major
4+
"@temporal-contract/worker": major
5+
"@temporal-contract/testing": major
6+
---
7+
8+
v8 audit remediation — a second full-surface pass hardening robustness, the unthrown integration, developer experience, and btravstack-family consistency before 8.0 stabilises.
9+
10+
**All packages.**
11+
12+
- ESM-only everywhere: `@temporal-contract/client` and `@temporal-contract/worker` drop their CJS output and legacy `main`/`module`/`types` fields; all four packages verify with `attw --profile esm-only`.
13+
- `@temporalio/*` peer ranges tightened from `^1` to `^1.16.0` — the real floor for the Schedule API and the top-level `@temporalio/common` search-attribute imports.
14+
- Error tags are exported as literal-typed constants (`CONTRACT_ERROR_TAG`, `ACTIVITY_ERROR_TAG`, `WORKFLOW_FAILED_ERROR_TAG`, …) so consumers match with `P.tag(CONST)` instead of hand-written strings; the classes consume the constants so tag and constant cannot drift.
15+
16+
**`@temporal-contract/contract`:**
17+
18+
- Type-helper renames to the family-standard `Infer*` prefix: `SignalNamesOf`/`QueryNamesOf`/`UpdateNamesOf`/`DeclaredErrorsOf``InferSignalNames`/`InferQueryNames`/`InferUpdateNames`/`InferDeclaredErrors`.
19+
- `defineActivity`'s `defaultOptions` key is renamed `activityOptions` (merge precedence unchanged).
20+
- Duration strings are validated against the `ms` grammar at `defineContract` time — `"5 minutos"`, `""`, and negative durations now fail at definition, naming the offending path, instead of at the worker.
21+
- Temporal-reserved names are rejected at `defineContract`: the `__temporal_` prefix and the exact `__stack_trace` / `__enhanced_stack_trace` query names.
22+
- Activity-only contracts are allowed — `workflows` may be `{}` when at least one global activity is declared (dedicated activity-pool task queues).
23+
- The typed-error wire encoding carries a provenance marker (`details[1] = { $tc: 1 }`). Data-less declared errors now require the marker to rehydrate, closing a false positive where any `ApplicationFailure` sharing a declared error's `type` string was surfaced as the typed domain error. A degrade-to-generic rehydration miss fires the new `onRehydrationMiss` diagnostic hook instead of failing silently.
24+
- The `/result-async` subpath is removed; the `_internal_*` helpers move behind a dedicated `@temporal-contract/contract/internal` subpath (not a public API).
25+
26+
**`@temporal-contract/worker`:**
27+
28+
- A shared activity referenced from several contract scopes must be implemented once at the global level or with the same function reference; two different implementations for one flattened name now throw at declaration time (previously the last one silently clobbered the rest).
29+
- The in-workflow handler binders are renamed to the `handle*` convention: `context.defineSignal`/`defineQuery`/`defineUpdate``context.handleSignal`/`handleQuery`/`handleUpdate` (no collision with the contract-authoring `define*` helpers or Temporal's own functions).
30+
- Previously-internal types are now exported so implementations can be factored out of the `declareWorkflow`/`declareActivitiesHandler` calls: `WorkflowContext`, `DeclareWorkflowOptions`, `WorkflowImplementation`, the child-workflow handle types, the signal/query/update handler-implementation types, `WorkflowInferActivity`, `DeclareActivitiesHandlerOptions`, `TypedContinueAsNewOptions`, plus the new `ActivityImplementationFor` / `GlobalActivityImplementationFor` helpers.
31+
- `qualifyFailure(errorType, options)` requires an `expected` discriminator (an error class, an array of classes, a predicate, or the explicit literal `"any"`). Causes matching `expected` are wrapped into the modeled `ApplicationFailure`; everything else — a `TypeError` from a bug, say — rides the **defect** channel instead of being mislabelled a business error. A matched inner `ApplicationFailure` with `nonRetryable: true` is inherited by default.
32+
- New `rethrowCancellation(error)` helper. When an activity declares an `errors` map, cancellation surfaces as `Err(ActivityCancelledError)`; generic error handling that folds every `Err` to a fallback would complete the workflow instead of cancelling it. The cancellation error classes' JSDoc documents the hazard and the helper.
33+
- Async query/update schemas are rejected at bind time (`ContractMisuseError`) rather than on the first live request.
34+
- `context.continueAsNew` can no longer have its validated `workflowType`/`taskQueue` overridden through the options bag.
35+
- `ChildWorkflowError` carries a structured `workflowName`; the input/output `ValidationError` subclasses carry a `readonly direction: "input" | "output"`.
36+
- `TypedWorker.create` verifies workflow registration by default — a contract workflow missing from the `workflowsPath` bundle, or an export whose name differs from its `workflowName`, fails creation with a contract-aware message. Opt out with `verifyWorkflowRegistration: false`.
37+
38+
**`@temporal-contract/client`:**
39+
40+
- Workflow outcomes are first-class typed errors on `executeWorkflow`/`handle.result()`: `WorkflowCancelledError`, `WorkflowTerminatedError`, `WorkflowTimeoutError` (each retaining the original `TemporalFailure` as `cause`) — no more `err.cause instanceof CancelledFailure` digging the matcher can't see.
41+
- Update and query operational failures are modeled instead of leaking as defects: `UpdateFailedError`, `UpdateRejectedError`, `QueryFailedError` (the last covering Temporal's `QueryNotRegisteredError`).
42+
- `P`-composable tag bundles (`WORKFLOW_START_ERROR_TAGS`, `WORKFLOW_OUTCOME_ERROR_TAGS`, `WORKFLOW_RESULT_ERROR_TAGS`) and a `tagPatterns(tags)` helper collapse the recurring multi-tag `match` arms.
43+
- `handle.raw` exposes the underlying `@temporalio/client` `WorkflowHandle`.
44+
- `ContractClient` and `TypedScheduleClient` are no longer constructible directly (use `typedClient.for(...)` / the schedule accessor); `ContractClient` exposes readonly `contract` and `taskQueue` getters.
45+
- Client interceptors may patch only `input`/`signalInput`, never identity fields such as `workflowName`.
46+
- `TypedScheduleHandle.update()` validates the updated action's `args` against the contract when its `workflowType` is a declared workflow. Invalid `DATETIME` search-attribute values (`new Date(NaN)`) are rejected.
47+
- Internals: the imperative `assertNoDefect` thunks are replaced with `AsyncResult` combinator chains, so defects flow through channels without manual re-wrapping.
48+
49+
**`@temporal-contract/testing`:**
50+
51+
- The factories move to the family option-bag convention: `createContractTest({ contract, workflowsPath, ... })` and `runActivity(definition, { implementation, input, env? })`.
52+
- New `runActivityHandler(definition, { ... })` routes through the real `declareActivitiesHandler` wrapping — input parse, output validation, contract-error wire conversion, and rehydration — so a test exercises what production does, not just the raw implementation.
53+
- `@unthrown/vitest` is wired in; the package's assertions use `toBeOk`/`toBeErrTagged`/`toBeDefect`.
54+
- `testcontainers` is now an optional peer dependency, required only for `createContractTest`; the Docker-free `/time-skipping` and `/activity` entries no longer pull it in.

docs/.vitepress/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ const DOCS_SIDEBAR = [
7878
{ text: "Contract surface", link: "/reference/contract-surface" },
7979
{ text: "Worker surface", link: "/reference/worker-surface" },
8080
{ text: "Client surface", link: "/reference/client-surface" },
81+
{ text: "Testing surface", link: "/reference/testing-surface" },
8182
{ text: "Errors", link: "/reference/errors" },
8283
{ text: "Glossary", link: "/reference/glossary" },
8384
{ text: "API reference", link: "/api/" },

docs/api/index.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,21 @@ from the previous `neverthrow`-based version.
1919

2020
- [@temporal-contract/testing](./testing/) - Testing utilities with testcontainers
2121

22+
Each package is documented per **public entry point**: contract as `index`
23+
(the root) and `errors`; worker as `activity`, `worker`, and `workflow`; testing
24+
as `activity`, `contract`, `extension`, `global-setup`, and `time-skipping`. The
25+
internal `@temporal-contract/contract/internal` entry carries no semver
26+
guarantee and is intentionally excluded.
27+
2228
## Hand-written reference
2329

24-
The generated pages above describe every exported symbol. For grouped,
25-
narrative reference — option tables, error channels, merge order — see:
30+
The generated pages above describe every symbol exported from those public
31+
entry points. For grouped, narrative reference — option tables, error channels,
32+
merge order — see:
2633

2734
- [Contract surface](/reference/contract-surface)
2835
- [Worker surface](/reference/worker-surface)
2936
- [Client surface](/reference/client-surface)
37+
- [Testing surface](/reference/testing-surface)
3038
- [Errors](/reference/errors)
3139
- [Glossary](/reference/glossary)

docs/examples/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Watch the execution at <http://localhost:8233>.
5959

6060
```bash
6161
# Integration tests — needs Docker
62-
pnpm --filter @temporal-contract/sample-order-processing-worker test
62+
pnpm --filter @temporal-contract/sample-order-processing-worker test:integration
6363
```
6464

6565
## Worth reading in the source

docs/explanation/nexus.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ contract covers its input and output again, at the cost of an extra hop:
119119
processOrder: {
120120
chargeViaNexus: ({ customerId, amount }) =>
121121
fromPromise(nexusClient.executeOperation("charge", { customerId, amount }),
122-
qualifyFailure("NEXUS_CHARGE_FAILED")),
122+
qualifyFailure("NEXUS_CHARGE_FAILED", { expected: NexusOperationError })),
123123
}
124124
```
125125

docs/explanation/validation-boundaries.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,18 +150,22 @@ _shape_ (a hand-rolled structural check; the contract package has no runtime
150150
schema-library dependency):
151151

152152
- `taskQueue` present and non-empty
153-
- at least one workflow
153+
- at least one workflow _or_ global activity (activity-only contracts are valid
154+
— a dedicated activity-pool worker needs no workflows)
154155
- no unknown keys at the contract root (strict — only `taskQueue`,
155156
`workflows`, `activities`)
156-
- every name a valid JavaScript identifier
157+
- every name a valid JavaScript identifier, and not a Temporal-reserved name
158+
(the `__temporal_` prefix, `__stack_trace`, `__enhanced_stack_trace`)
159+
- every duration option a valid `ms` string (`"5 minutes"`, `"30s"`) — a typo
160+
like `"5 minutos"` fails here, not at the worker
157161
- every schema slot Standard Schema compatible
158162
- no activity-name collisions in the flat runtime namespace — reusing the
159163
_same_ definition object across workflows is fine (that is one activity, not
160164
a collision); two different definitions under one name is rejected, with a
161165
hint to hoist the shared activity to the global `activities` block
162166
- no workflow name colliding with a global activity name (they share the root
163167
of the worker's implementations map)
164-
- no unknown keys in `defaultOptions`
168+
- no unknown keys in `activityOptions`
165169

166170
Because this runs at import time, a malformed contract fails when the process
167171
starts rather than when a workflow first executes.

docs/explanation/workflow-determinism.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ is the boundary worth guarding.
7575

7676
```typescript
7777
let approved = false;
78-
context.defineSignal("approve", () => {
78+
context.handleSignal("approve", () => {
7979
approved = true;
8080
});
8181
```

docs/how-to/add-activity-middleware.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,11 @@ The most useful thing middleware does is extend the typed context that flows to
6767
implementations. Use `declareActivityMiddleware` to pin the in and out types:
6868

6969
```typescript
70-
import { declareActivityMiddleware, type EmptyContext } from "@temporal-contract/worker/activity";
70+
import {
71+
ApplicationFailure,
72+
declareActivityMiddleware,
73+
type EmptyContext,
74+
} from "@temporal-contract/worker/activity";
7175
import { ErrAsync } from "unthrown";
7276

7377
const withTenant = declareActivityMiddleware<EmptyContext, { tenantId: string }>(
@@ -96,7 +100,9 @@ export const activities = declareActivitiesHandler({
96100
// context.tenantId: string
97101
fromPromise(
98102
gateway.charge(context.tenantId, customerId, amount),
99-
qualifyFailure("CHARGE_FAILED"),
103+
// `expected` is required: name the anticipated failure class (or a
104+
// predicate). Everything else rides the defect channel.
105+
qualifyFailure("CHARGE_FAILED", { expected: GatewayError }),
100106
),
101107
},
102108
},
@@ -145,7 +151,10 @@ export const activities = declareActivitiesHandler({
145151
activities: {
146152
processOrder: {
147153
chargeCard: ({ customerId, amount }, { context }) =>
148-
fromPromise(context.gateway.charge(customerId, amount), qualifyFailure("CHARGE_FAILED")),
154+
fromPromise(
155+
context.gateway.charge(customerId, amount),
156+
qualifyFailure("CHARGE_FAILED", { expected: GatewayError }),
157+
),
149158
},
150159
},
151160
});

docs/how-to/configure-a-worker.md

Lines changed: 54 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,14 @@ const worker = await TypedWorker.create({
1919
connection,
2020
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
2121
activities,
22-
}).get();
22+
}).getOrThrow();
2323

24-
await worker.run().get();
24+
await worker.run().getOrThrow();
2525
```
2626

2727
`taskQueue` comes from the contract, so you never repeat it. Everything else on
2828
Temporal's `WorkerOptions` is accepted and passed through.
29+
`TypedWorker.create` replaces the old free `createWorker` function.
2930

3031
## Handle a failed start
3132

@@ -46,19 +47,55 @@ if (result.isDefect()) {
4647
process.exit(1);
4748
}
4849

49-
await result.value.run().get();
50+
// Past the guard the only remaining variant is `Ok` — the error channel is
51+
// `never` — so unwrap and run.
52+
await result.getOrThrow().run().getOrThrow();
5053
```
5154

52-
`.get()` is the terse form — on a defect it rethrows the original cause with its
53-
stack intact, which is usually what you want at process startup.
55+
`.getOrThrow()` is the terse form — on a defect it rethrows the original cause
56+
with its stack intact, which is usually what you want at process startup. (With
57+
a `never` error channel `.get()` would compile too, but `.getOrThrow()` is the
58+
one idiom that stays correct if a modeled error is ever added.)
5459

5560
`run()` has the same shape: it returns `AsyncResult<void, never>`, so a worker
5661
that fails while running surfaces as a defect (a `TechnicalError` cause) rather
57-
than a rejected promise — `await worker.run().get()` rethrows it at the edge.
58-
The underlying Temporal `Worker` stays available as `worker.raw` for anything
59-
the typed surface doesn't cover (`worker.raw.getState()`,
62+
than a rejected promise — `await worker.run().getOrThrow()` rethrows it at the
63+
edge. The underlying Temporal `Worker` stays available as `worker.raw` for
64+
anything the typed surface doesn't cover (`worker.raw.getState()`,
6065
`worker.raw.runUntil(...)`).
6166

67+
## Verify workflow registration
68+
69+
`TypedWorker.create` verifies workflow registration by default: it imports the
70+
`workflowsPath` module and checks that every contract workflow is exported
71+
under its declared name. Creation fails (a `TechnicalError`-caused defect) when
72+
73+
- a contract workflow is missing from the bundle — a forgotten
74+
`declareWorkflow` export that would otherwise surface only when the first
75+
task for it was dispatched; or
76+
- a workflow is exported under a name that differs from its `workflowName`
77+
Temporal registers workflows by export name, so the mismatch would register
78+
it as the wrong workflow type.
79+
80+
Opt out with `verifyWorkflowRegistration: false`, for example when the
81+
workflows module intentionally exports helpers whose names shadow contract
82+
workflows:
83+
84+
```typescript
85+
const worker = await TypedWorker.create({
86+
contract: orderContract,
87+
connection,
88+
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
89+
activities,
90+
verifyWorkflowRegistration: false,
91+
}).getOrThrow();
92+
```
93+
94+
The check is best-effort: it only runs when `workflowsPath` is provided
95+
(prebuilt `workflowBundle`s are skipped), and a module that cannot be imported
96+
in the main thread is skipped silently — `Worker.create`'s bundler is the
97+
authority on whether the module loads at all.
98+
6299
## Resolve the workflows path
63100

64101
Temporal bundles workflow code into an isolated sandbox, so it needs a _path_,
@@ -109,9 +146,9 @@ const worker = await TypedWorker.create({
109146
connection,
110147
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
111148
// no `activities`
112-
}).get();
149+
}).getOrThrow();
113150

114-
await worker.run().get();
151+
await worker.run().getOrThrow();
115152
```
116153

117154
This is the split-deployment pattern: workflows are deterministic and
@@ -134,7 +171,7 @@ const worker = await TypedWorker.create({
134171

135172
// Cap the rate at which this worker pulls new work.
136173
maxTaskQueueActivitiesPerSecond: 50,
137-
}).get();
174+
}).getOrThrow();
138175
```
139176

140177
Activity concurrency is the usual bottleneck. Raise it for I/O-bound work; keep
@@ -143,14 +180,14 @@ it low for CPU-bound or memory-hungry activities.
143180
## Shut down gracefully
144181

145182
```typescript
146-
const worker = await TypedWorker.create({/* ... */}).get();
183+
const worker = await TypedWorker.create({/* ... */}).getOrThrow();
147184

148185
process.on("SIGTERM", () => {
149186
console.log("draining...");
150187
worker.shutdown();
151188
});
152189

153-
await worker.run().get(); // resolves once in-flight tasks finish
190+
await worker.run().getOrThrow(); // resolves once in-flight tasks finish
154191
await connection.close();
155192
```
156193

@@ -180,16 +217,16 @@ const [orderWorker, shipmentWorker] = await Promise.all([
180217
connection,
181218
workflowsPath: workflowsPathFromURL(import.meta.url, "./order.workflows.js"),
182219
activities: orderActivities,
183-
}).get(),
220+
}).getOrThrow(),
184221
TypedWorker.create({
185222
contract: shipmentContract,
186223
connection,
187224
workflowsPath: workflowsPathFromURL(import.meta.url, "./shipment.workflows.js"),
188225
activities: shipmentActivities,
189-
}).get(),
226+
}).getOrThrow(),
190227
]);
191228

192-
await Promise.all([orderWorker.run().get(), shipmentWorker.run().get()]);
229+
await Promise.all([orderWorker.run().getOrThrow(), shipmentWorker.run().getOrThrow()]);
193230
```
194231

195232
They share the connection. Split them into separate processes when their
@@ -216,7 +253,7 @@ const worker = await TypedWorker.create({
216253
namespace: "my-namespace.a1b2c",
217254
workflowsPath: workflowsPathFromURL(import.meta.url, "./workflows.js"),
218255
activities,
219-
}).get();
256+
}).getOrThrow();
220257
```
221258

222259
## Add logging

docs/how-to/continue-as-new.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -48,19 +48,21 @@ Base the decision on something deterministic. `context.info` exposes Temporal's
4848
```typescript
4949
implementation: async (context, args) => {
5050
let processed = args.processed;
51+
let cursor = args.cursor;
5152

5253
while (true) {
53-
const batch = await context.activities.fetchBatch({ cursor: args.cursor });
54+
const batch = await context.activities.fetchBatch({ cursor });
5455
if (batch.items.length === 0) {
5556
return { processed };
5657
}
5758

5859
await context.activities.processBatch({ items: batch.items });
5960
processed += batch.items.length;
61+
cursor = batch.nextCursor; // advance, so the next fetch makes progress
6062

6163
// Temporal's own signal that history is getting long.
6264
if (context.info.continueAsNewSuggested) {
63-
return context.continueAsNew({ cursor: batch.nextCursor, processed });
65+
return context.continueAsNew({ cursor, processed });
6466
}
6567
}
6668
};
@@ -118,14 +120,16 @@ return context.continueAsNew(
118120
{ subscriptionId: args.subscriptionId, cycle: args.cycle + 1 },
119121
{
120122
workflowRunTimeout: "7 days",
121-
retry: { maximumAttempts: 3 },
123+
workflowTaskTimeout: "10 seconds",
122124
memo: { tenant: args.tenantId },
123125
},
124126
);
125127
```
126128

127-
`workflowType` and `taskQueue` are derived from the contract and cannot be set
128-
here.
129+
`TypedContinueAsNewOptions` is Temporal's `ContinueAsNewOptions` minus
130+
`workflowType` and `taskQueue` (derived from the contract, and ignored if you
131+
try to set them). There is no `retry` option — a continued run inherits the
132+
chain's retry policy; use activity retry policies for step-level retries.
129133

130134
## Drain handlers first
131135

0 commit comments

Comments
 (0)