Running status tracker for the core build. Each phase has a full spec under docs/spec/. Check boxes off as you land each acceptance criterion so someone picking this up after a break (including future-you) can see exactly where we are.
Naming note. The specs refer to packages as
@forumone/claude-cms-*. The actual package names in this repo usethroughline. Mentally substitute when reading the specs.
| Phase | Title | Status |
|---|---|---|
| C0 | Monorepo Scaffold | ✅ Done |
| C1 | Plugin Architecture | ✅ Done |
| C2 | Design Contract Package | ✅ Done |
| C3 | Reference Design System | ✅ Done |
| C4 | Core Plumbing Package | ✅ Done |
| C5 | Component Server | ✅ Done |
| C6 | Publishing Server | ✅ Done |
| C7 | Approvals Server | ✅ Done |
| C8 | Audit Query Server | ✅ Done |
| C9 | Integrations Server | ✅ Done |
| C10 | Workflows Package | ✅ Done |
| C11 | Email Package | ✅ Done |
| C12 | Forms Package | ✅ Done |
| C13 | CLI Scaffolder | ✅ Done |
| C14 | Documentation (markdown only; site deferred) | ✅ Done |
Spec: docs/spec/1.0-plan.md. Checklists are below.
| Phase | Title | Status |
|---|---|---|
| 1.0-P0 | Stabilize 0.x | ✅ Done |
| 1.0-P1 | Jobs interface (0.x) | ✅ Done |
| 1.0-P2 | Moves and MCP tools (0.x) | ✅ Done |
| 1.0-P3 | Consolidate and release 1.0 | ✅ Done |
| 1.0-P4 | Migrate forumone-2026 | ✅ Done |
| 1.0-P5 | After 1.0 | ⬜ When a second site arrives |
Spec: docs/spec/C0-monorepo-scaffold.md
Goal: Turborepo + pnpm workspaces + changesets + shared tooling + CI that builds, tests, and publishes to npm on tagged releases.
- Repo at
github.com/forumone/throughlinewith Turborepo + pnpm workspaces - Shared TypeScript, ESLint, Prettier configs exist as internal packages
- Changesets initialized and configured for
@forumonescope - CI workflow runs build, typecheck, lint, test on every PR
- Release workflow connected to npm (trusted publishing via OIDC); opens release PRs on changeset merges
- README, CONTRIBUTING, LICENSE exist
-
pnpm build,pnpm typecheck,pnpm lint,pnpm testall run cleanly from root - Branch protection on main requires PR + CI
- npm publish round-trip verified end-to-end —
@forumone/throughline-design-contract@0.2.0and@forumone/throughline-reference-ds@0.2.0live on npm via trusted publishing (OIDC)
Notes:
- Skipped C0.8/C0.9 smoke-test round-trip in favor of exercising the publish pipeline with the first real package (C2).
- Removed the scaffolded
smoke-testpackage in PR #6. - Since
@forumonescope-level trusted publishing isn't available, every new package needs a one-time bootstrap: placeholder publish vianpx setup-npm-trusted-publish <name> --access public, then configure a trusted publisher in the npm web UI (npmjs.com/package/<name>/access→ GitHub Actions → ownerforumone, repothroughline, workflowrelease.yml). Done once for all 12 packages currently planned (10 pre-provisioned for C4–C13, plus the 2 already published).
Spec: docs/spec/C1-plugin-architecture.md
Goal: Define the Payload plugin contract every core package satisfies — the shape, the MCP mounting pattern, the composition model, the shared options contract.
-
@forumone/throughline-plugin-contractexists as a private workspace package -
CorePlugin,BaseCorePluginOptions,McpToolDefinition,McpToolContextexported - Example plugin pattern (
examplePlugin) compiles and shows every required step - Plugin registry (
register,has,get,list,requireCapability) implemented -
apps/playground/exists with Next.js 16 + Payload 3.83, runs locally against Postgres - Example plugin imported into the playground and the app boots (verified end-to-end by creating the first admin user)
-
docs/plugin-composition.mdwritten -
docs/building-plugins.mdwritten -
pnpm build,pnpm typecheck,pnpm lintall pass from root
Notes:
- Packages use
throughlinenotclaude-cms. - Example's global hook uses
hooks.afterError(the only top-levelConfig.hooksin Payload v3) rather than the spec'shooks.afterChange. - Playground Postgres runs on
:5433to avoid collision with native Postgres installs.
Spec: docs/spec/C2-design-contract.md
Goal: Build the package that defines what it means to be an AI-ready design system. Exports the Zod schema for component contracts, the manifest format, the manifest loader, the CI lint rules. Every future design system satisfies this.
-
packages/design-contract/scaffolded (package.json, tsconfig, vitest config, src) -
ComponentContractSchemadefined with every section (identity, composition, content, tokens, accessibility, examples, behavior) -
ManifestSchemadefines the aggregated JSON withcontractVersionliteral-gated at1.0.0 -
LoadedManifestexposesgetComponent,requireComponent,listComponents,listByCategory,listCategories,getToken -
loadManifestvalidates and returnsLoadedManifest; throws with path-qualified errors -
loadManifestFromUrlfetches and validates a remote manifest -
lintManifestreports errors (unknown components/tokens/story IDs) and warnings (empty anti-examples, brief intent) -
formatLintIssuesandassertManifestCleanhelpers - Main entry exports schema + manifest + loader;
/lintsubpath exports lint helpers - 43 tests across schema/manifest/loader/lint; all passing
- README with authoring, loading, linting examples
- Changeset committed; package ready to publish as 0.1.0
Notes:
- Package name is
@forumone/throughline-design-contract(spec saysclaude-cms-design-contract). - Recursive
ContentFieldschema usesz.lazywith explicitz.ZodType<Output, Def, Input>three-generic form becauseexactOptionalPropertyTypes: true+.default(false)onrequiredmake input and output diverge. _fixtures.tsholds test fixtures and is excluded from the emitteddist/.
Spec: docs/spec/C3-reference-ds.md
Goal: A brand-neutral design system with 10–12 components, full contracts, generated manifest, Storybook, and CI validation. Test fixture for core + demonstration of contract compliance + starting template for clients without their own DS.
-
packages/reference-ds/scaffolded with Storybook 10, vitest + jsdom + Testing Library, shared tsconfig/eslint - 12 components: Hero, SectionIntro, Prose, MediaBlock, Card, CardGrid, CTASection, Stats, FAQ, Quote, Divider, Spacer
- Every component has React + CSS Modules + Storybook stories + unit tests +
ComponentContract - Token system (colors, typography, spacing, radii) as TS constants with
build-tokens-css.tsgeneratingtokens.css+prefers-color-scheme: darkoverride -
scripts/build-manifest.tsdiscovers contracts, validates againstManifestSchema, writesdist/manifest.json -
scripts/validate.tsrunslintManifestagainst the manifest and consumesstorybook-static/index.jsonfor storyId resolution - Storybook builds cleanly with
addon-a11y; index.json contains every story referenced by a contract - Main entry exports all 12 components;
./manifest,./styles.css,./tokenssubpath exports - Unit tests for every component (render + key semantics/ARIA)
- README, changeset for 0.1.0
-
pnpm build,pnpm typecheck,pnpm lint,pnpm testgreen from root - CI runs
build-storybook+validateas a dedicated parallel job - Storybook deployment (Vercel or Chromatic) — pending account/tooling decision; tracked as a standalone follow-up
Notes:
- Package name is
@forumone/throughline-reference-ds(spec saysclaude-cms-reference-ds). - Storybook
^10.3.5on@storybook/react-vite; spec'saddon-essentialsis rolled into SB 10 core and not listed separately. - CSS Modules type declaration lives at
src/css-modules.d.ts. - Contracts explicitly set defaulted fields (
behavior,antiExamples) because the output type ofComponentContractis stricter than the input type underexactOptionalPropertyTypes: true. - Storybook story IDs derive from each story's
title; multi-word components use space-separated titles ('Section Intro'→section-intro--default) so the IDs match the contracts. - Gesso (
forumone/nextjs-project) was not reused: only 5/12 components overlap, it's coupled to@storybook/nextjs, has no contracts, and lives inside a monolithic Next.js template. It informed naming conventions only. The lint integration pattern from the previous note (Storybook'sindex.json→availableStoryIds) is wired intoscripts/validate.ts.
Spec: docs/spec/C4-core-plumbing.md
Goal: The foundation every server package depends on — audit log, MCP auth pattern, shared types beyond the plugin contract, Inngest client factory, env handling, _meta convention. Unblocks C5–C9 to be developed in parallel.
-
packages/core/scaffolded as a publishable package; subpath exports for./audit,./auth,./events,./mcp,./env - Audit log:
auditPlugin(extends Payload config, attaches writer viaSymbol.for, registers in plugin registry),createAuditWriter(fire-and-forget, optional Inngest emission),createAuditCollection(immutable, indexed for common queries),getAuditWriterfor peer plugins - MCP auth:
createApiKeysCollection(admin-only access, SHA-256 hashed keys, raw key surfaced once via__rawKey),createBearerTokenAuthenticator(validates against hashed storage, expiry-aware) - Events:
CoreEventstaxonomy +FrameworkEventsmodule-augmentation seam,createInngestClientfactory - MCP handler:
createMcpHandler(JSON-RPC over HTTP, auth, tool dispatch, error formatting,zod-to-json-schemafortools/list) -
_metahelpers:McpMetaSchemaandwithMeta(shape)so plugin authors can attach prompt/reasoning context to any tool input - Env conventions:
ENV_VARSconstants,validateBaseEnv,requireEnv,optionalEnv - Logger:
defaultLogger+createNamedLoggerwith tagged scoping - Utilities:
shallowDiff,generateId - Re-exports of common contract types (
CorePlugin,Logger,McpToolDefinition, etc.) so consumers don't need to import the plugin-contract package separately - 71 unit tests covering every subsystem; fire-and-forget audit semantics tested explicitly
- Playground app wires
auditPluginandcreateApiKeysCollection, exercises the architecture end-to-end
Notes:
- Package name
@forumone/throughline-core(spec saidclaude-cms-core). - Inngest pinned to
^4.0.0, not the spec's^3.0.0(Inngest 3 is end-of-life). The constructor'ssigningKeyfield was removed in v4 — signing is set via env on the serve handler instead — so the factory's option list is shorter than the spec's. FrameworkEventshas an empty body (it's the augmentation seam); the eslintno-empty-object-typerule is suppressed at the declaration with an explanatory comment.- Hashing uses Web Crypto (
crypto.subtle.digest) so the same code runs in Node and edge runtimes.
Spec: docs/spec/C5-component-server.md
Goal: First custom MCP server. Exposes a design system manifest as conversational primitives (list components, get contracts, suggest components, validate compositions, detect anti-patterns) against any contract-compliant DS.
-
packages/components/scaffolded as a publishable package with a single main entry; subsystem files live insrc/ - Plugin options: discriminated
ManifestSourceunion (object | url | payload-collection) + matcher config; Zod schema with path-qualified errors - Manifest loader supports all three sources; URL source honors
refreshIntervalandrefresh()always re-fetches; Payload-collection source falls back to the doc itself when nodatawrapper is present - TF-IDF matcher with
intentweighted twice over description; tokenizer drops short tokens + a small stop-word list; verified against the reference DS for editorial intents - Composition validation:
forbiddenAdjacent,maxPerPage,requiredSiblings(warning), unknown components, unknown variants - Anti-pattern detection: per-component
antiExamplesmatched by structural heuristics (multiple-class, end-of-page); de-duplicated per (blockIndex, pattern) - Seven MCP tools:
list_components,get_contract,get_variants,get_tokens,suggest_for_intent,validate_composition,find_anti_pattern - Action tools take
AuditWriteras a constructor dep so they're unit-testable without a Payload instance; every consequential call writes adesign.*audit record with_meta.userPrompt/_meta.reasoningforwarded - Plugin uses
requireCapability('audit-log')and fails at init when audit isn't registered; eager manifest load surfaces source errors at deploy time, not at first request - MCP handler attached to Payload via Symbol; endpoint at
${routePrefix}/mcp(default/api/components/mcp) fetches the handler at request time - 53 tests passing — option validation, manifest loading across all three source types (with TTL behavior), TF-IDF ranking against the real reference-ds manifest, composition rules, anti-pattern detection, every tool's happy path and error cases
- Playground composes
componentsPluginafterauditPluginand points it at@forumone/throughline-reference-ds/manifest; build passes
Notes:
- Embeddings matcher deferred per the spec's note: "Don't ship half-working embeddings." TF-IDF lands now; the matcher interface is strategy-agnostic so swapping in embeddings won't change the tool surface.
- JSON-imported manifests need an
as unknown as Manifestcast because TS doesn't widen JSON literal types to the schema's tuple types (e.g.placement). The plugin's Zod validation enforces the actual shape at load time. - Action tools take their
AuditWriteras a constructor dep instead of callinggetAuditWriterinside the handler. Cleaner composition, easier unit-testing.
Spec: docs/spec/C6-publishing-server.md
Goal: The framework's trust boundary. Policy-gated publish pipeline wrapping Payload's update: composition validation, a11y checks, required-field checks, embargo, approval gating, downstream event orchestration.
-
packages/publishing/scaffolded with options surface (PublishableCollection / AccessibilityCheck / ApprovalResolver), Zod-validated config, and resolveCollection helper - Three built-in accessibility checks (alt text / heading hierarchy / link labels) +
accessibilityChecksoption for client extensions - Seven-step pipeline (exist / composition / accessibility / required-fields / embargo / approval / execute;
link-targetssince forumone-2026#756) withrunPublishPipelineandrunPreflightPipeline - Composition step calls the components plugin's validator in-process via
Symbol.for('@forumone/throughline/components-validator') -
beforeChangehook injected on every publishable collection rejects direct_statuswrites unless the request carries the bypass context flag - Five MCP tools:
publish,unpublish,schedule_publish,get_publish_status(read-only),rollback. Each takes_metaand writespublishing.*audit records (exceptget_publish_status). - Plugin uses
requireCapability('audit-log')and fails at init when audit isn't registered - MCP handler attached to Payload via Symbol; endpoint at
/api/publishing/mcp - 80 tests covering options validation, every accessibility check, every pipeline step, the runner, the hook, and every tool's happy/error paths
- Companion change in
@forumone/throughline-components: composition validator now exposed via Symbol so peer plugins can call it without round-tripping through MCP. Patch bump. - Playground composes
publishingPluginagainst a Pages collection that has seo / policy / layout / publishedAt / scheduledPublishAt fields; build passes
Notes:
- Inngest pinned to
^4.0.0(matches the rest of the framework; spec said^3.0.0but that major is EOL). routePrefixdefaults to/publishing(not/api/publishing) — Payload prepends/apiautomatically. Documented inbuilding-plugins.md.- Tools take
AuditWriteras a constructor dep instead of callinggetAuditWriterinside the handler — same pattern as components. - Rollback restores the version into draft and stops there; users explicitly call
publishif they want it live. Lighter-weight than the spec's "validate-then-publish" approach but easier to reason about. - Built-in
heading-hierarchycheck is structural (multiple Heroes flagged) rather than rendering-based. Full a11y rendering analysis is a Phase 2 service.
Spec: docs/spec/C7-approvals-server.md
Goal: Workflow + conversational approvals. Collection schema, MCP tools, HMAC action tokens, and the approval resolver the Publishing Server consumes.
-
packages/approvals/scaffolded with options surface (groups, GroupResolver, validateOptions resolving tokenSecret) - Approvals collection: target / request / decision / workflow-state field groups, indexes for the common queries, admin-only update + create-denied so only the plugin's tools mint records
- HMAC action tokens (
generateActionToken,verifyActionToken,buildActionUrl) using Web Crypto so the same code works in Node and edge runtimes; constant-time signature compare; configurable max-age (default 14 days) - Approval resolver auto-attached to Payload via
Symbol.for('@forumone/throughline/approvals-resolver'); publishing'sapprovalStepreads it lazily so adding approvals to a config doesn't require re-wiring publishing's options - Five MCP tools (
request_approval,respond_to_approval,get_approval_status,list_pending_approvals,list_my_requests) with audit emission tied to the rightapproval.*actions - Action endpoint at
/api/approvals/actionwith confirmation-on-first-hit, single-use tokens viaconsumedTokens,approval/decidedInngest event, and audit emission - Self-approval blocked, group-membership check on respond, pending-status guard
- Plugin uses
requireCapability('audit-log')and fails at init when audit isn't registered - 49 unit tests covering options validation, token round-trip + tampering + expiry, resolver mapping, every MCP tool's happy/error paths, and every action-endpoint flow (missing token, invalid token, confirmation render, decision recorded, replay rejected, already-decided no-op)
- Companion change in
@forumone/throughline-publishing:approvalStepfalls back to the symbol lookup. Patch bump. - Playground composes
approvalsPluginbetween componentsPlugin and publishingPlugin; build passes
Notes:
- Inngest pinned to
^4.0.0(matches the rest of the framework; spec said^3.0.0). routePrefixdefaults to/approvals(not/api/approvals) since Payload prepends/apiautomatically.- First-decision-wins is a Phase 1 deliberate choice — multi-party approvals (legal AND comms both required) are deferred until a real client needs them.
- Action tokens are single-use and 14-day default lifetime. Replay protection is
consumedTokenson the approval record. - Group resolution is left to the consumer via
groupResolver.resolveUsers; core doesn't hardcode membership lookup logic. Playground stubs it with[]until the playground gains a richer Users schema. - Action endpoint HTML is intentionally minimal/unbranded — clients can register a custom-branded endpoint that calls into
verifyActionTokenif they want richer pages.
Spec: docs/spec/C8-audit-query-server.md
Goal: Expose the audit log (written by C4) as conversational query tools so Claude can answer "what did I change this week?", "who published the homepage?", etc. Small package, high leverage.
-
packages/audit/scaffolded with options surface (collectionSlug,readAccess) andvalidateOptionsgate - Conversational formatting helpers (
formatRelativeTime,formatAuditEvent) handling user/integration/system actor variants and dropping non-string optional fields cleanly underexactOptionalPropertyTypes - Five purpose-built MCP tools —
query_audit,get_change_history,who_changed_what,what_changed_in_range,get_recent_failures— with bounded result sets, conservative defaults, and prose summaries Claude can relay directly - Tiered access control: admin/editor for broad-scope tools;
who_changed_whatalways allows self-lookup so anyone can ask about their own changes without knowing their user ID -
auditQueryPlugin(named to disambiguate from core'sauditPlugin) registers the MCP handler, requires theaudit-logcapability, and fails fast ifauditPluginisn't installed first - 26 unit tests covering relative-time edge cases, formatter variants, every tool's filter shape, and access denial paths via a fake Payload that applies the
equals/greater_than_equal/less_than_equaloperators we use - Playground composes
auditQueryPlugin({})after publishing; full root build/lint/typecheck/test green
Notes:
routePrefixdefaults to/audit(Payload prepends/api, so the endpoint is/api/audit/mcp).- The plugin layers an admin/editor gate on top of the collection's own access control. The
readAccessoption sits on the collection in core; tool-level gating is hard-coded for now and can be unified once a client has a real role-model deviation. - Tools take a
{ payload, collectionSlug }deps object — same shape as the other server packages. Tools are cast throughunknown as McpToolDefinition[]at the array level because each factory returns a narrowedMcpToolDefinition<typeof inputSchema>for handler-input safety.
Spec: docs/spec/C9-integrations-server.md
Goal: Plugin architecture for connecting Payload to external systems. Integration interface, registry, per-instance config collection, MCP tools, and a generic outbound-webhook integration as the first concrete example.
-
packages/integrations/scaffolded withIntegration<Config>contract (configFields, validateConfig, subscribes, createFunctions, mcpTools, healthcheck) andIntegrationContext(loadInstances, updateStatus, recordAudit) types -
IntegrationRegistry— synchronous, per-plugin-init, rejects duplicate ids; covered by direct unit tests - Integrations collection with
name/integrationType/enabled/config(json) / read-onlylastSyncAt+lastSyncStatus+lastError; admin-only writes, admin/editor reads, indexes for the common queries - beforeChange hook runs the registered integration's
validateConfigbefore write; rejects unknown types with the registered list in the error message - Webhook integration: HMAC-SHA256 via Web Crypto with RFC 4231 known-answer test vectors pinned to lock the wire format, configurable event filter, includeFullPayload toggle, timeoutSeconds, HEAD-based healthcheck (also accepts 405)
- Webhook Inngest functions:
webhook-deliversubscribes to all framework events, retries 5x, isolates failures via per-instance step.run;webhook-manual-triggerlistens forintegration/manual-syncfrom the trigger_sync tool - Five MCP tools (
list_integrations,get_integration_status,trigger_syncadmin-only,test_integration,list_integration_types) with conservative limits; every consequential call writes audit - Plugin requires
audit-logcapability; exposes registry+context via Symbols (getIntegrationRegistry/getIntegrationContext) so the client app's Inngest endpoint can serve integration functions -
docs/integrations-wiring.mddocuments the Inngest-endpoint composition pattern with a copy-pasteable snippet - 36 unit tests covering registry, options, collection access + validation, HMAC vectors, payload extraction, webhook validateConfig, and every MCP tool's happy + access-denied paths via fake Payload + fake Inngest helpers
- Playground composes
integrationsPlugin({ inngest })after auditQueryPlugin; full root build/lint/typecheck/test green
Notes:
- Inngest 4.x's
createFunctiontakes a single options object with atriggersarray (the spec's three-arg form is from older Inngest releases). trigger_syncis admin-only because triggering an outbound POST is a write-side action even though it doesn't change configuration. Read tools (status, list, test) only need editor.- Outbound headers are
x-throughline-event,x-throughline-signature,x-throughline-timestamp(the spec usedx-claude-cms-*). Receivers verifysha256=<hex>against the body using the shared signing secret. - Plugin registers integrations into the registry but does not serve their Inngest functions; the client app composes them via
getIntegrationRegistry(payload). Documented as a Phase 1 wart indocs/integrations-wiring.md.
Spec: docs/spec/C10-workflows.md
Goal: Composable Inngest functions for common async work (revalidation on publish, scheduled publishes, stale-approval expiry, audit echo, healthchecks). Clients import what they want and merge into their Inngest endpoint.
-
packages/workflows/scaffolded as factories-only (no Payload plugin); next listed as an optional peer (peerDependenciesMeta.next.optional = true); ambientnext/cacheshim so the dynamic import typechecks undermodule: NodeNext - Shared types module covers every factory's options surface with built-in defaults, optional id overrides, and JSDoc on each option
-
createRevalidateOnPublishFunctionsubscribes tocontent/page.{published,unpublished,rolled_back}and revalidates page path / listings / sitemap; site-suppliedurlBuilders(required since 0.5 — the built-in pages/posts builders guessed paths);revalidateoption for non-Next.js frontends; default revalidator dynamic-importsnext/cache -
createTagRevalidationHooks— collectionafterChange/afterDeleteand globalafterChangehooks that drop Next cache tags (revalidateTag(tag, { expire: 0 })), skip draft writes via publishing'sisDraftWrite, and log rather than throw (debug outside a Next request, error otherwise);createCacheTagsbuilds every tag for writers and readers alike, also on the dependency-free/cache-tagssubpath -
createExecuteScheduledPublishesFunctioncron (default every 5 min) finds_status: draftdocs pastscheduledPublishAt, calls Publishing Server's MCPpublishtool with Bearer auth (env fallbackPUBLISHING_SYSTEM_API_KEY), counts published vs blocked vs error outcomes, never throws on policy rejections -
createExpireStaleApprovalsFunctiondaily cron (2am UTC) flips pending approvals past expiresAt toexpired, writesapproval.expiredaudit viagetAuditWriter, firesapproval/expiredInngest event for downstream notifications; supports both string and{ id }requestedBy shapes -
createAuditEventEchoFunctionsubscribes toaudit/event.recorded, firesnotification/send-approval-{request,decision}for the approval lifecycle, runs each handler in its own step.run so fan-out failures isolate -
createHealthcheckFunctionruns configurable checks isolated behind step.run, reports failures viaonFailure, firessystem/healthcheckheartbeat every tick; shipscreatePayloadReachableCheckandcreateManifestReachableCheckhelpers - 28 unit tests via fake Inngest (captures createFunction definitions and
sendevents) + fake Payload (appliesequals/less_than/less_than_equal/exists); covers triggers, default schedules, custom URL builders, env-fallback API key, policy-rejection counting, audit fan-out, and healthcheck onFailure routing
Notes:
- No playground hookup: the playground doesn't ship an Inngest endpoint yet, so workflows are documented and tested but not exercised end-to-end here. A later phase will add the endpoint and wire factories into it.
- Inngest 4.x's
createFunctiontakes a single options object withtriggers(array) andcrontriggers expressed as{ cron }. The spec used the older 3-arg form. executeScheduledPublishesdeliberately does not retry on policy errors. Cron retries on a permanent error (e.g. composition failure) would log noise without making progress; the document stays at_status: draftuntil an admin intervenes.audit-event-echois the single fan-out point.email(C11) subscribes to thenotification/send-approval-*events fired here.
Spec: docs/spec/C11-email.md
Goal: Resend wrapper, React Email base layout with brand tokens, transactional templates (approval request / decision / expired), and Inngest functions subscribing to C10's audit-echo events.
-
packages/email/scaffolded againstreact.jsontsconfig (JSX) with neutral defaultEmailBrandTokens(black on white, system sans, "Your Site"),mergeTokenshelper, and tokens.ts JSDoc explaining why brandName lands in three places (header, From, footer) -
validateOptionsenforces inngest + RESEND_API_KEY/EMAIL_FROM_ADDRESS env fallback + resolver functions + buildActionUrl; surfaces missing config at boot rather than at first send - Resend client wrapper with lazy imports of both
resendand@react-email/renderso tests run with neither installed; per-call optionalreplyToplus adefaultReplyTo - EmailLayout shared chrome (brand-name header, dividers, footer disclaimer); sticks to React Email primitives (
Body,Container,Section,Hr,Button) to keep Outlook / Gmail / Apple Mail consistent - ApprovalRequestEmail with optional Why section, Preview CTA, three-action row (Approve / Request changes / Discuss), and an expiration footer line — buttons laid out in a nested HTML table because flexbox is unreliable in Outlook
- ApprovalDecisionEmail with three variants (granted / declined / changes-requested), color-coded headlines, optional decision-notes callout, decision-aware "Next step" prose, and a Preview button shown on granted/changes-requested but hidden on declined
- ApprovalExpiredEmail (intentionally plain — name, date, "ask Claude to request approval again")
- Three Inngest notification functions:
notify-approval-request(subscribes tonotification/send-approval-request, sends one email per approver innotifiedApproverswith each in its ownstep.runso bounces retry without re-sending),notify-approval-decision(subscribes tonotification/send-approval-decision, maps audit action → variant, emails the requester),notify-approval-expired(subscribes toapproval/expired, notifies the requester) -
emailPluginexposes the email client and the three functions on the Payload instance via Symbols;getEmailClientandgetEmailFunctionshelpers let the client app's Inngest endpoint compose them inserve() - Templates render to both HTML and plaintext from the same React tree on every send (accessibility, deliverability, HTML-refusing clients)
- 61 unit tests across tokens, options validation (env fallbacks + missing-config errors), Resend wrapper (mocked), each template (HTML + plaintext + show/hide behaviour + custom token theming), shared helpers, and each notification function (subscribe trigger, recipient routing, per-approver step.run isolation, error envelopes)
Notes:
- Inngest 4.x's
createFunctiontakes one options object with atriggersarray. Spec used the older 3-arg form. react.jsontsconfig (jsx: react-jsx) is required for the .tsx templates; library tsconfig isn't enough.- Templates expose
<!-- -->comment markers between adjacent text + expression children (a React renderer artifact). Tests assert on identity-bearing fragments (names, titles, URLs) rather than verbatim sentences so renderer changes don't break them. - Brand-name centralization is deliberate: From display name falls back through
EMAIL_FROM_NAME→tokens.brandName→'Your Site'. Same brand string the layout shows in the header. - No playground hookup: the playground doesn't ship an Inngest endpoint yet; the email plugin needs that to fire. Documented as a follow-up phase.
Spec: docs/spec/C12-forms.md
Goal: Policy-aware forms. Wraps Payload's Form Builder with privacy notices, a11y, spam protection, destination allowlist, submitter confirmation. MCP tools for conversational create/update/query. Public submission endpoint + Inngest fan-out.
-
packages/forms/scaffolded againstreact.jsontsconfig (JSX) with options validation: required Inngest, ≥1 unique-labeled allowedDestinations with per-type value checks (email contains@, webhook ishttps://),>=32-char ipHashSecret withFORMS_IP_HASH_SECRETenv fallback. Resolved-config object centralizes defaults so the rest of the package consumes one shape. - Allowlist enforced at three layers — MCP tool, Forms collection's beforeChange hook (covers admin / direct-API writes), and the fan-out worker (drops + warns on labels removed by a redeploy)
- addFormPolicyFields appends a single
policygroup (privacy notice, consent toggle + label, spam protection [honeypot + per-form rate limit], destinations array with allowlist-bound select, submitter-confirmation block); does not mutate the input - Public submit endpoint registered via Form Builder's
formOverridesslot at<routePrefix>/submit; pipeline: honeypot (silent 200 if filled, so bots don't pivot) → form lookup (404 if missing) → consent (server-side; client bypass doesn't work) → rate limit (Postgres-counted per (form, ipHash) per hour, with per-form override) → persist sanitized [{field, value}] rows → fireform/submission.received - IP hashing via HMAC-SHA256 / Web Crypto with the per-deployment secret so cross-deployment hash tables can't be joined;
extractClientIphonors x-forwarded-for / x-real-ip / cf-connecting-ip with a 0.0.0.0 fallback - Six MCP tools:
list_allowed_destinations(omits raw values),validate_form(dry run),create_form(admin/editor; allowlist + accessibility + submitter-confirmation pointer; audits viaform.created),update_form_fields(refuses to remove fields the existing submitterConfirmation references),update_form_destinations(replace-all semantics; rejects unknown / duplicate labels),get_form_submissions(defaults to redacted output;includePii=truerequires admin or form-admin) - Adds
form.updatedto the AUDIT_ACTIONS taxonomy in core (used by both update tools); patch bump on@forumone/throughline-core - FormSubmissionEmail (admin notification with labeled field list and optional admin link) + SubmitterConfirmationEmail (auto-reply with paragraph splitting) — render to HTML and plaintext from the same React tree
- Four Inngest functions exposed via
getFormsFunctions(payload)for the client app's Inngest endpoint to compose:form-fan-out(per-destination dispatch + optional submitter-confirmation),form-email-destination(lazy email-client lookup; throws on missing client so Inngest retries),form-webhook-destination(HMAC-signed POST with retry-on-non-2xx),form-submitter-confirmation(skip+log on misconfiguration so one bad form doesn't poison the queue) -
formsPluginrequiresaudit-log+emailcapabilities; refuses to init without them - 86 unit tests across options, destinations, policy fields, honeypot, IP hashing, rate limiting, the submit endpoint, the six MCP tools, the four Inngest functions, and the two templates (HTML + plaintext)
Notes:
- @payloadcms/plugin-form-builder peers are version-locked to payload (3.83.0 plugin needs 3.83.0 payload), not caret-compatible. The package.json pins the exact form-builder version.
- Inngest 4.x's
createFunctionshape (single options object withtriggers) is used throughout. Spec used the older 3-arg form. nextand Inngest endpoint serving are the consumer's responsibility;getFormsFunctions(payload)returns the four functions and the README documents the wire-up.- No playground hookup: playground doesn't ship an Inngest endpoint and doesn't have
formBuilderPluginwired. The forms package is documented and tested but not exercised end-to-end here. Same Phase 2 follow-up as workflows + email.
Spec: docs/spec/C13-cli.md
Goal: create-throughline CLI. pnpm create @forumone/throughline my-client-site → ready-to-run monorepo, env stubs, example collection + DS reference, first-deployment checklist.
-
@forumone/create-throughlinepublished to npm (pnpm create @forumone/throughline <name>) - Seven-question interactive flow via
@clack/prompts; project name + npm scope validated - Generator with
{{var}}+{{#if}}/{{else}}template renderer;.templatesuffix stripped on write -
templates/base/produces a working pnpm monorepo with Next.js 16 + Payload 3.83 -
templates/with-reference-ds/overlay re-exports the reference DS + manifest -
templates/without-reference-ds/overlay produces a placeholder + README - Generated
payload.config.tswires all eight Throughline plugins in the right order withTODOmarkers for client-specific resolvers - Generated
apps/web/src/app/api/inngest/route.tsregisters every framework function (revalidate, scheduled-publish, expire-approvals, audit-echo, healthcheck, email, forms, integrations) - Generated
.env.examplelists every required secret with comments - Post-install printer adapts to deployment + database choices and the reference-DS choice
- 36 unit tests (renderer, prompts validators, generator end-to-end)
Spec: docs/spec/C14-docs.md (live site deferred; markdown-in-repo only)
Goal: Documentation that makes the framework usable by someone who didn't build it — architecture, getting started, per-package API reference, customization guides, DS-contract authoring guide.
The original spec called for a Nextra-based docs site. We're shipping the content now (under docs/) and deferring the live-site publishing flow to a later phase.
- Top-level
docs/README.mdDiátaxis-style index - Getting-started tutorials (4 pages): scaffolding, first Claude connection, first publish, deploying to Vercel
- Concepts (6 pages): architecture overview, plugin composition, the trust boundary, design system contracts, event-driven workflows, client-agnostic core
- How-to guides (9 pages): adding a collection, authoring contracts, theming emails, adding an integration, configuring approvers, customizing accessibility checks, migrating content, upgrading, building a plugin
- Operations (5 pages): deployment options, env vars, observability, security model, Phase 2 expansions
- Reference (13 pages): one per published package, plus an index
- Live docs site (Nextra + Vercel deployment) — deferred to a future phase
- Auto-generated typedoc reference replacing the hand-authored reference pages — deferred
Spec: docs/spec/1.0-plan.md. Add first, break once: P1 and P2 ship as 0.x and reach forumone-2026's production one piece at a time, and nothing breaks until P3. Each phase ends at a gate. Tick a box when it is merged on main, and link the PR.
Gate: forumone-2026 pins a released 0.x, and #166 is closed.
- Merge the #166 platform fixes: #186, #190, #191, #192, #193, #196, #197, #198, #200
- forumone-2026 takes each one up in the PR that pins it, removing its workaround (forumone-2026 #789, #794, #792, #793, #795; tracked and closed in forumone-2026#782)
- forumone-2026's CI refuses a pin that is not on this repo's
main(check-throughline-pin.sh, forumone-2026#789) - forumone-2026 aligns every
@payloadcms/*package on 3.90.2, and drops the unusedplugin-form-builder(forumone-2026#789) -
check-block-propsloads TSX args and CSS modules (#207, #208; forumone-2026#796) - Release the 0.x final: #199, then #209 for #208
- forumone-2026 pins the release commit
5613d4c(forumone-2026#797) - #166 closed
Gate: the workflow tests pass against both adapters, the playground runs on Payload Jobs, and forumone-2026 runs inngestJobs in production with its function ids unchanged. Additive only: nothing merged may break the site's imports, options or schema.
-
defineJob,emit, and astepwithrun,sleepUntilandsendEvent, in@forumone/throughline-workflows(#211) -
inngestJobs(client)adapter, keeping today's function ids (#211) -
payloadJobs()adapter, with a per-minute Vercel cron orautoRun(#213) - Move failure handling (the
job-failureswriter, error reporting) into the adapter layer: both adapters take oneonFailurefor every job, andcreateTerminalFailureHandlerserves either (#211, #213) - Port every workflow and plugin job to
defineJob; run the workflow tests against both adapters- The six workflows in
@forumone/throughline-workflows(#214) - email: the three approval notifications, plus
emailJobs()and an optionalinngest(#215) - integrations:
Integration.createJobs, the webhook,integrationsJobs(), andemitin place ofinngest(#216) -
formsWon't do: forms is not in 1.0 (decision 4)
- The six workflows in
- Playground runs on
payloadJobs, with an end-to-end test of publish revalidation and scheduled publishing - Measure "publishes on the minute" under
payloadJobs, and record the result in the spec (it settles decision 3): on the minute it lands within a second, and mid-minute it lands on the next tick - Release as 0.x (#212): core 0.11, workflows 0.6, email 0.4, integrations 0.10, with the site's Inngest environment pinning moved into core (#218)
- forumone-2026 adopts
inngestJobsin production (moving its Inngest environment pinning in), with function ids unchanged and a scheduled publish verified across the deploy (forumone-2026#798, promoted 2026-10-02: the same 31 registrations in production, preview down to 19 with no crons, and a publish scheduled before the deploy landed after it)
Gate: every moved feature runs from the package in forumone-2026's production, with its site copy deleted, and each has its MCP tool and a parity test. Additive only, as in P1. Each row is a 0.x release, then a site PR.
- Media usage for blocks stored as JSON, delete guards, "Used on" panel; tools
find_referencesandcan_delete(incorefor now) (#220; forumone-2026#799) - Content health view; tool
find_content_needing_attention(#223; forumone-2026#802) - Content calendar; tool
get_content_calendar(#225; forumone-2026#804) - "Your work" dashboard; tool
list_my_work(#227; forumone-2026#806) - Command palette, and the Reports nav; tool
search_content(#229; forumone-2026#808) - Field kit: slug and trashed-slug guard, character count,
publishedAt,revisedAt,usedBy,unlisted,mapFields; toolcheck_slug(incorefor now) (#232; forumone-2026#809) - Access hardening for Payload's internal collections (#234; forumone-2026#810)
-
list_job_failuresoverjob-failures(#234; forumone-2026#810) - Terminal-failure handlers for the five functions that have none: the three email notifications and the webhook integration's two. Found while counting forumone-2026's Inngest registrations in P1. On a jobs adapter they get its
onFailurefor free. (#234; forumone-2026#810) - Vercel Blob client-upload hardening: in Throughline, at
@forumone/throughline-core/media, and not offered to Payload (#236; forumone-2026#811) - Every default field name and slug matches forumone-2026's current one, so adoption needs no data migration: each site migration in P2 only adds a new tool's API-key column
Promoted to forumone-2026's production 2026-10-02 (live at forumone-2026 c331cb12), and checked there: the dashboard panels, both reports, the Reports nav, Cmd-K and a media item's "Used on" panel.
Gate: 1.0.0 is on npm. Breaking changes start here, and only here.
- Cut
v0from the last 0.x release.release.ymlruns onv0as well, andv0's changesets config setsbaseBranch: "v0"(#239, #240;v0cut atfb354b9, whose packages are #237's release) - forumone-2026's
check-throughline-pin.shacceptsmainorv0, and itsCLAUDE.mdsays platform fixes start fromv0(forumone-2026#812) - Enter changesets pre-release mode on
main(1.0.0-next.N), withCONTRIBUTING.mdsaying where a 0.x fix goes - Consolidate into
@forumone/throughlinewith subpath exports, folding workflows into its owners (publishing, approvals, audit, integrations). The map isdocs/spec/1.0-exports.md(#242).- core and plugin-contract (#243)
- publishing, into
/publishing,/editorial,/clientand/rsc(#245) - workflows, split by owner into
/jobs,/jobs/inngest,/jobs/payload,/publishing,/approvals,/audit,/integrationsand/cache-tags; the six Inngest-shaped factories removed (#246) - audit, approvals, components, integrations and email; email's three Inngest-shaped factories removed, and every internal import pointed at its declaring module rather than an entry (#247)
- Consolidate design-contract and design-system-payload into
@forumone/throughline-design-system, and publish it (design-system-payload isprivatetoday); it builds todistnow, rather than shipping TypeScript source - Fold reference-ds into create-throughline as template and test fixture: private, at
packages/create-throughline/reference-ds, still the one source the scaffold vendors from - Make plugin-contract, the capability registry and the MCP collector internal, with the accessors the Inngest route used (
getEmailFunctions,getIntegrationRegistry,getIntegrationContext) -
throughline({...})registers every plugin in order and wires the MCP collector (spec:docs/spec/1.0-throughline-call.md, #250); it also lists the jobs the options call for, and the playground and scaffold use it -
resend, React Email andinngestbecome optional peers, loaded only by the subpath that needs them;payloadis the one required peer, andsrc/peers.test.tsholds every entry to its table - One fixed version across the three published packages (changesets
fixed);create-throughlinejoins the other two at1.0.0-next.N - Bootstrap npm trusted publishing for the new package names (see the C0 note). Both publish through it:
@forumone/throughline@1.0.0-next.0(#244) and@forumone/throughline-design-system@1.0.0-next.0(#249) - Leave forms out of 1.0; tag its last 0.x source. The tag is the release's own,
@forumone/throughline-forms@0.7.9, andv0keeps the source - Regenerate the scaffolder for the new shape; CI generates a site from it and builds that site. The
Scaffoldworkflow installs it against the checkout's packed packages and runs the scaffold's own gates, through a migration,next buildand the smoke pack (#257) - Import codemod (
throughline migrate-imports) covering every 0.x import path, including the P1/P2 temporary homes. On forumone-2026: 163 files rewritten, five imports left for thethroughline()call to replace - Migration guide in
docs/guides/upgrading.md, and reference docs for the three packages-
docs/guides/upgrading.md, andupgrading-core-packages.mdfor releases after it - reference docs for the three packages:
docs/referenceis the one home, a page per package and per plugin, and the 0.x pages stay onv0 - the rest of
docs/on 1.0: getting started, guides, concepts, operations and the root README, each claim checked against the code
-
- Code gaps the docs sweep found, each documented as it stands until fixed. Two were left for after 1.0 and are under P5:
-
block-status-writesskipscreate, so a document created aspublished(REST, or Payload's MCP CRUD) goes live with no pipeline; and a non-draft save to a live page changes live content without it, approval-required pages included. Both refused now; derived data passes withDERIVED_WRITE_CONTEXT - an accessibility issue of severity
warningis dropped, not reported -
Integration.createFunctionsis required thoughthroughline()runs onlycreateJobs -
auditQueryPlugin'sreadAccessis declared and read by nothing -
job-failurestakes no sidebar group - forms leftovers:
form.*audit actions and theformsserver name, the webhook'sform/submission.received,emailEnv's reason text. Theform.*audit actions, theformsserver name and the webhook's filter option stay: each is a stored enum value
-
- PR snapshots (
prdist-tag) andnextsnapshots on merge (#264). Every pull request touching a package publishes<version>-pr-<n>-<sha>underpr, first1.0.0-pr-264-881f3a4; thenextsnapshot of each merge tomainstarts once pre-release mode is exited - forumone-2026's
chore/throughline-nextbuilds against eachnext.N: forumone-2026#814, draft, green infastandverifyon1.0.0-next.2. It is the P4 migration rehearsed — no schema change, every function id and MCP tool unchanged — and becomes the P4 pull request at1.0.0 - Exit pre-release mode and release
1.0.0(#266, #268; released 2026-10-03).v0now publishes under thev0dist-tag so a 0.x fix cannot movelatest(#267)
Gate: forumone-2026 runs 1.0 in production, and the submodule is gone. Packaging only, since the code arrived in P1 and P2. The steps are in the spec, under "Migrating forumone-2026".
- One site PR: dependencies, codemod,
throughline({...}), generated files, repo rules (forumone-2026#814, at1.0.1; the codemod rewrote 163 files, and HubSpot and Greenhouse became jobs) -
migrate:createfinds nothing to change (run with the Blob token set);payload-types.tsandselect-options.jsonregenerate byte-identical - Full
fastandverify, a prerender-manifest diff, and an admin smoke test against a local Postgres. Green on1.0.0-next.2,1.0.0and1.0.1; the manifest is identical tomain's (27 prerendered and 8 dynamic routes, no change in cache life); the smoke test foundrequest_approvalunable to store a request on Postgres, in 0.x as well, fixed in1.0.1(#270) - A preview with a publish scheduled ten minutes out: it published on time
- Merged and promoted to production (forumone-2026#814;
liveat3194a383on1.0.1, 2026-10-03) - The submodule,
check:boundaryandcheck-throughline-pin.shretired (forumone-2026#814);v0closed: it releases and runs CI no more, and stays for its history
When a second site shows which configuration points are real: Okta generalised to OIDC, draft preview, HubSpot and Greenhouse, narration, AI Suggest, llms.txt.
Left from P3 and P4, each documented as it stands until fixed:
- editorial, references,
check_slugandlist_job_failuresrecord nosystem.erroron a throw. Each needs a value in the audit log'smcp_serverenum, which is a migration in every host, so it ships with one: 1.1, and a test now holds every collector server name to an audit name - The scaffold:
.env.localwritten at the root, wherenext devinapps/webdoes not read it; every new user defaults toadmin; stale "Payload MCP API Keys" and "Pick a User" wording. Nowapps/web/.env.local; the first account is the admin and every later one an editor, with roles and groups admin-set; the wording with the template docs sweep -
request_approvalstores the request and then reports the call as failed when itsapproval/requestedevent cannot be sent; publishing treats the same failure as a warning on a write that landed. Now a warning inrequest_approval,respond_to_approvaland the email action endpoint — which also could not record a decision on Postgres, itsdecidedBythe same string-id defect #270 fixed in the tools