Skip to content

feat(world-postgres): support Hook minimum retention - #3276

Merged
NathanColosimo merged 16 commits into
codex/hook-retention-localfrom
codex/hook-retention-postgres
Aug 4, 2026
Merged

feat(world-postgres): support Hook minimum retention#3276
NathanColosimo merged 16 commits into
codex/hook-retention-localfrom
codex/hook-retention-postgres

Conversation

@NathanColosimo

@NathanColosimo NathanColosimo commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Summary

  • implement experimental_minRetention in the Postgres World
  • advertise the hookRetention World capability
  • keep terminal Hooks discoverable and their tokens unavailable until retention ends
  • reject retention beyond 30 days by default with a non-retryable WorkflowWorldError
  • allow WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS to mirror another production limit
  • preserve immediate token release through hook.dispose()
  • enable the existing Hook-retention E2E contract for Postgres

This PR is stacked on #2866. The core API/event contract is already on main; this PR only implements that contract for @workflow/world-postgres.

Behavior

experimental_minRetention is measured from Hook creation:

  • an active run keeps its Hook valid even after the deadline
  • a terminal run keeps its Hook discoverable only while retention remains
  • retained terminal Hooks reject resume attempts through the existing terminal-run path
  • after the deadline, reads treat the Hook as absent and the next creator can reuse the token
  • hook.dispose() deletes the Hook and releases its token immediately
  • the Postgres World accepts up to 30 days by default
  • WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS can change that limit

Implementation

The existing Hook row remains the only token-reservation state.

  • token_retention_until is a nullable timestamptz column because the runtime supplies an absolute JavaScript Date
  • events.create() rejects an oversized hook_created request before starting any database write
  • the limit error is a WorkflowWorldError with status 400
  • terminal cleanup deletes Hooks with no remaining retention and preserves only future deadlines
  • Hook reads use one availability predicate: the deadline is still future, or the owner run is not terminal
  • token creation deletes an expired terminal row immediately before inserting its replacement
  • expired rows for tokens that are never reused remain physically stored but are invisible to reads; broad garbage collection belongs with Postgres run-retention cleanup

This intentionally does not solve the existing non-atomic token-admission race tracked by #2376. Adding a token uniqueness/locking protocol is the separate atomic-admission project, not part of this retention-only port.

Verification

  • dependency build for the Local, Postgres, and Vercel World stack passed
  • @workflow/world-postgres TypeScript check passed
  • focused Postgres integration tests passed against a real Postgres container
  • rejection coverage verifies that no Hook or event row is written
  • the configured-limit acceptance case passed
  • focused Postgres Next.js E2E: hookMinRetentionWorkflow - terminal Hook cannot resume and its token stays unavailable

Docs Preview

Page Preview
createHook() minimum retention v5
World configuration reference v5
Postgres World v5
@changeset-bot

changeset-bot Bot commented Aug 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 03ae58a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@workflow/world-postgres Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
example-nextjs-workflow-turbopack Ready Ready Preview Aug 3, 2026 11:43pm
example-nextjs-workflow-webpack Ready Ready Preview Aug 3, 2026 11:43pm
example-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-astro-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-express-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-fastify-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-hono-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-nestjs-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-nitro-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-nuxt-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-sveltekit-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-tanstack-start-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workbench-vite-workflow Ready Ready Preview Aug 3, 2026 11:43pm
workflow-docs Ready Ready Preview, v0 Aug 3, 2026 11:43pm
workflow-swc-playground Ready Ready Preview Aug 3, 2026 11:43pm
workflow-tarballs Ready Ready Preview Aug 3, 2026 11:43pm
workflow-web Ready Ready Preview Aug 3, 2026 11:43pm
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

🧪 E2E Test Results

All tests passed

E2E Test Summary

Summary
Passed Failed Skipped Total
✅ ▲ Vercel Production 1455 0 250 1705
✅ 💻 Local Development 1633 0 227 1860
✅ 📦 Local Production 1633 0 227 1860
✅ 🐘 Local Postgres 1633 0 227 1860
✅ 🪟 Windows 155 0 0 155
✅ 📋 Other 1026 0 214 1240
✅ vercel-multi-region 27 0 0 27
Total 7562 0 1145 8707
Details by Category

✅ ▲ Vercel Production

App Passed Failed Skipped
✅ astro 126 0 29
✅ example 126 0 29
✅ express 126 0 29
✅ fastify 126 0 29
✅ hono 126 0 29
✅ nextjs-turbopack 151 0 4
✅ nextjs-webpack 151 0 4
✅ nitro 126 0 29
✅ nuxt 126 0 29
✅ sveltekit 145 0 10
✅ vite 126 0 29

✅ 💻 Local Development

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
✅ nextjs-webpack-stable 155 0 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 📦 Local Production

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
✅ nextjs-webpack-stable 155 0 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 🐘 Local Postgres

App Passed Failed Skipped
✅ astro-stable 129 0 26
✅ express-stable 129 0 26
✅ fastify-stable 129 0 26
✅ hono-stable 129 0 26
✅ nextjs-turbopack-canary 136 0 19
✅ nextjs-turbopack-stable 155 0 0
✅ nextjs-webpack-canary 136 0 19
✅ nextjs-webpack-stable 155 0 0
✅ nitro-stable 129 0 26
✅ nuxt-stable 129 0 26
✅ sveltekit-stable 148 0 7
✅ vite-stable 129 0 26

✅ 🪟 Windows

App Passed Failed Skipped
✅ nextjs-turbopack 155 0 0

✅ 📋 Other

App Passed Failed Skipped
✅ e2e-local-dev-nest-stable 129 0 26
✅ e2e-local-dev-tanstack-start- 129 0 26
✅ e2e-local-postgres-nest-stable 129 0 26
✅ e2e-local-postgres-tanstack-start- 129 0 26
✅ e2e-local-prod-nest-stable 129 0 26
✅ e2e-local-prod-tanstack-start- 129 0 26
✅ e2e-vercel-prod-nest 126 0 29
✅ e2e-vercel-prod-tanstack-start 126 0 29

✅ vercel-multi-region

App Passed Failed Skipped
✅ nextjs-turbopack 27 0 0

📋 View full workflow run

@NathanColosimo
NathanColosimo marked this pull request as ready for review August 2, 2026 01:36
@NathanColosimo
NathanColosimo requested review from a team and ijjk as code owners August 2, 2026 01:36

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Exhaustive Record<AttributeKey, ...> in attribute-panel.tsx is missing the tokenRetentionUntil key that was added to HookSchema, causing TS2741 and breaking every Vercel build.

Fix on Vercel

@VaguelySerious VaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review: approved.

AI Review: Note

Before merge, both Worlds should enforce a default maximum retention of 30d, overridable by environment variable.

experimental_minRetention is unbounded today. createHook() validates the webhook combination and the World capability, then hands whatever the caller passed to parseDurationToDate with no ceiling, so '10y' or a Date in 2099 is honored verbatim by both backends. Two reasons that matters more here than it looks:

  • Neither World reaps an expired retained Hook on its own. world-postgres never deletes the row unless someone creates a Hook with the same token again; world-local only reaps lazily when a read happens to touch it. An unbounded deadline is therefore effectively a permanent row, and the token stays reserved for as long as it says.
  • The deadline is pinned once at createHook() and re-pinned from the persisted hook_created on replay. That is the right design, but it also means a mistake is durable: there is no later opportunity to correct an over-long value for runs already in flight.

A few things worth settling while wiring the cap:

  • Clamp, don't reject. Silently truncating 90d to 30d is surprising, so the clamp should be visible in a log at minimum.
  • Clamp before the value reaches the event log, so the persisted deadline is the effective one and replay stays deterministic. A consequence to state explicitly: raising or lowering the env var later will not retroactively move deadlines already persisted, which I think is the behavior you want.
  • Use one env var name shared by both Worlds rather than a WORKFLOW_LOCAL_* / WORKFLOW_POSTGRES_* pair, so the two cannot drift, and document it per the repo rule that every user-configurable environment variable is documented.
  • The create-hook docs page should state the default ceiling next to the existing retention section, since "30d" is the example value there and would sit exactly at the limit.
@VaguelySerious

Copy link
Copy Markdown
Member

Use one env var name shared by both Worlds rather than a WORKFLOW_LOCAL_* / WORKFLOW_POSTGRES_* pair, so the two cannot drift, and document it per the repo rule that every user-configurable environment variable is documented.

My agent misunderstood, I think it's fine to have separate variables

vercel Bot and others added 2 commits August 3, 2026 21:35
…is missing the `tokenRetentionUntil` key that was added to `HookSchema`, causing TS2741 and breaking every Vercel build.

This commit fixes the issue reported at packages/web-shared/src/components/sidebar/attribute-panel.tsx:426

## Bug

Commit `ad58321` added `tokenRetentionUntil: z.coerce.date().optional()` to `HookSchema` in `packages/world/src/hooks.ts:106`. This adds `tokenRetentionUntil` to the inferred `Hook` type.

In `packages/web-shared/src/components/sidebar/attribute-panel.tsx`, `AttributeKey` is a union that includes `keyof Hook`, so `tokenRetentionUntil` becomes a required member of the **exhaustive** `Record<AttributeKey, (value: unknown, context?: DisplayContext) => ...>` object literal `attributeToDisplayFn` (starting at line ~426).

Because the literal had no `tokenRetentionUntil` entry, `tsc` fails:

```
src/components/sidebar/attribute-panel.tsx(426,7): error TS2741:
Property 'tokenRetentionUntil' is missing in type '{ ... }' but required in type
'Record<AttributeKey, (value: unknown, context?: DisplayContext | undefined) => ReactNode>'.
```

This breaks `@workflow/web-shared#build` and therefore every Vercel deployment (17 failing deployments observed, all with this identical error).

## Fix

Added a `tokenRetentionUntil` entry to `attributeToDisplayFn`, placed alongside the other Hook date fields (`lastReceivedAt`, `disposedAt`):

```ts
tokenRetentionUntil: timestampWithTooltipOrNull,
```

`tokenRetentionUntil` is a `Date` field, and `timestampWithTooltipOrNull` (defined at line 402) is the display helper used by all the other surfaced date fields (`createdAt`, `startedAt`, `completedAt`, `retryAfter`, `resumeAt`, `occurredAt`). Given the intent of `ad58321` was to expose the hook retention deadline, surfacing it as a tooltip-annotated timestamp is the consistent choice.

Only `attributeToDisplayFn` is a fully exhaustive `Record<AttributeKey, ...>`; the other maps are `Partial<...>` / `Set`, so no other edits are required.

## Verification

`node_modules` are not installed in this sandbox, so `tsc` could not be executed directly. Verified structurally instead: the newly added `tokenRetentionUntil` entry (line 449) references `timestampWithTooltipOrNull`, which is defined in-file at line 402 and already used by the sibling date entries, so the fix satisfies the missing-key requirement without introducing new type errors.

Co-authored-by: Vercel <vercel[bot]@users.noreply.github.com>
Co-authored-by: VaguelySerious <mittgfu@gmail.com>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

📊 Workflow Benchmarks

commit 03ae58a · Tue, 04 Aug 2026 00:02:28 GMT · run logs

Backend: vercel · app: nextjs-turbopack

Metric Scenario Best (ms) P75 (ms) P90 (ms) P99 (ms) Samples
TTFS step 1237 (+52%) 🔻 1451 🔴 (+32%) 🔻 1490 🔴 (+26%) 🔻 1648 🔴 (-5.3%) 30
TTFS stream 1241 (+360%) 🔻 1305 🔴 (+22%) 🔻 1363 🔴 (+26%) 🔻 1423 🔴 (+26%) 🔻 30
TTFS hook + stream 1484 (+22%) 🔻 1607 🔴 (+20%) 🔻 1624 🔴 (+19%) 🔻 1711 🔴 (+19%) 🔻 30
STSO 1020 steps (inline) 173 (+90%) 🔻 482 (+209%) 🔻 569 (+199%) 🔻 806 (+149%) 🔻 1016
STSO 1020 steps (queue-hop) 2561 (-26%) 💚 3604 (+3.8%) 3604 (+3.8%) 3604 (+3.8%) 3
WO 1020 steps 425974 (+184%) 🔻 425974 (+184%) 🔻 425974 (+184%) 🔻 425974 (+184%) 🔻 1
SL stream latency 112 (+9.8%) 162 🔴 (+6.6%) 192 🔴 (+4.3%) 259 🔴 (-15%) 30
SO stream overhead (text) 117 (-19%) 💚 198 (-18%) 💚 244 (-36%) 💚 741 (+70%) 🔻 30
SO stream overhead (structured) 106 (-8.6%) 195 (-8.5%) 218 (-7.2%) 396 (+34%) 🔻 30
📈 STSO distribution vs main (inline / queue-hop histograms)

1020 steps (inline)

Cumulative STSO time: main 145277ms → this run 415304ms (Δ +270027ms, +186%)

   50-100 ms  ┃                         main   9  this   0    -9
  100-150 ms  ┃███████████████████████  main 717  this   0  -717
  150-200 ms  ┃██████                   main 220  this  16  -204
  200-250 ms  █░┃                       main  42  this  97   +55
  250-300 ms  █░░░┃                     main  16  this 157  +141
  300-350 ms  █░┃                       main   7  this  89   +82
  350-400 ms  █░░░┃                     main   4  this 142  +138
  400-450 ms  █░░░░┃                    main   1  this 171  +170
  450-500 ms  █░░┃                      main   1  this 121  +120
  500-550 ms  ░░┃                       main   0  this  95   +95
  550-600 ms  ░┃                        main   0  this  57   +57
  600-650 ms  ┃                         main   1  this  29   +28
  650-700 ms  ┃                         main   0  this  16   +16
  700-750 ms  ┃                         main   0  this  11   +11
  750-800 ms  ┃                         main   0  this   4    +4
  800-850 ms  ┃                         main   0  this   3    +3
  850-900 ms  ┃                         main   0  this   1    +1
  900-950 ms  ┃                         main   0  this   3    +3
 950-1000 ms  ┃                         main   0  this   1    +1
1000-1050 ms  ┃                         main   0  this   2    +2
4050-4100 ms  ┃                         main   0  this   1    +1

1020 steps (queue-hop)

Cumulative STSO time: main 3471ms → this run 9317ms (Δ +5846ms, +168%)

2500-3000 ms  ░░░░░░░░░░░░░░░░░░░░░░░┃  main 0  this 1  +1
3000-3500 ms  ███████████████████████┃  main 1  this 1  +0
3500-4000 ms  ░░░░░░░░░░░░░░░░░░░░░░░┃  main 0  this 1  +1
ℹ️ Metric definitions & methodology

The collapsed STSO distribution section above buckets every step gap of the sequential-steps run (not a sampled window), split by whether the step ending the gap ran inline — in the same warm process as the step before it, so the gap is pure framework overhead — or after a queue-hop — the first step of a fresh process, which pays queue dispatch, client reinit and event-log replay. Bars overlay the two runs: is main, marks where this run lands, bridges the gap when this run has more samples in a bucket.

Best/P75/P90/P99 deltas compare against the most recent benchmark run on main at the time of this run. 🔻 flags a delta worse than +15%, 💚 one better than −15%.

Metrics — TTFS: time to first step body (in-deployment start() → first step body, deployment clocks) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · SL: stream latency (in-deployment write → read propagation, readAt - writtenAt) · SO: stream overhead (end-to-end write+consume time beyond the modelled generation window)

Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · stream latency: parallel reader/writer steps on a dedicated stream; SL is the in-deployment write->read propagation (readAt - writtenAt) · stream overhead (text): writer streams 300 variable-length text token deltas paced at 100/s for 3s (a haiku-size LLM's token throughput) while a parallel reader drains the whole stream; SO is the end-to-end write+consume time beyond the 3s generation window (overhead/backpressure) · stream overhead (structured): same workload as stream overhead (text), but each delta is an AI-SDK-style structured object ({ type: 'text-delta', id, text }) instead of a raw string, so the SO gap vs the text scenario is the added serialization cost

🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 · SL 50/60/125 · SO 250/500/1000

All metrics are measured from deployment-side timestamps only. Runs are triggered by an in-deployment route that stamps the anchor (clientStart) right before start(), so the CI runner’s request and its path through api.vercel.com sit outside every measured window. TTFS = in-deployment start() → first step body (turbo uses the in-process fast path, non-turbo the dispatch path), and includes the VQS dispatch hop plus any /flow cold start. STSO/WO are measured between step bodies on the deployment. SL is measured inside the workflow (parallel reader/writer steps), so it no longer includes the api.vercel.com read path.

Cold starts are kept in the numbers on purpose — they are part of real bursty-workload latency. The workbench deployment cold-starts the /flow invocation for a large fraction of runs, inflating P75+; the Best column shows the fastest (warm-start) sample for comparison.

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

Duplicate tokenRetentionUntil key in HookSchema object literal causes TS1117, breaking @workflow/world build and all Vercel deployments.

Fix on Vercel

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

No backport to stable for 99f4aeb (AI decision).

This is feature work: it implements the experimental_minRetention Hook-retention contract for @workflow/world-postgres, advertises a new hookRetention World capability, adds a new schema column/migration and a new WORKFLOW_POSTGRES_HOOK_RETENTION_LIMIT_DAYS config option (carrying a minor changeset). It also explicitly builds on the core API/event contract introduced by #2866 on main, so it depends on main-only behavior. New capabilities like this belong on main only, not on a maintenance line.

To override, re-run the Backport to stable workflow manually via workflow_dispatch and paste this commit SHA into the ref input:

99f4aeb03da40c2fa31e5d5bedb822b9d38145e5
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants