Canonical, decisive plan to converge Mark's Infowall fork and the shipped SonicJS v3 plugin framework into ONE production-ready, future-proof base. Grounded in the adversarially-verified dimension comparisons and the actual SonicJS source on this branch (
lane711/plugin-system-define, PRs #841/#842/#844 already merged).Verification posture (read first): every load-bearing claim about SonicJS is cited to live source on this branch (
packages/core/src/...) and re-verified. Claims about Infowall are grounded in Mark's actual source, which IS available read-only at/Users/lane/Dev/refs/infowall-ai-main— a sibling SonicJS fork with the same monorepo shape (it is simply not part of THIS repo's build). The relevant SDK lives atinfowall-ai-main/packages/core/src/plugins/sdk/{define-plugin,capabilities,events,register-plugins,types}.tsand.../services/{cron-registry,email-service-singleton,hook-system-singleton}.ts. Two crux dimensions were re-verified directly against that source: the hook-event catalog (sdk/events.ts— a discriminated union keyed by atypefield, withauth:*/cron:tickfully typed andcontent:*/app:*/request:*carrying a permissive index signature) and the capability vocabulary (sdk/capabilities.ts—storage:*,hooks.content-{read,write}:register, plus a realnormalizeCapability()
CAPABILITY_RENAMESmechanism). The "port from Infowall" tasks below are written as self-contained specs (signature / inputs / outputs / test) so they're buildable on their own — AND each can be cross-checked against the cited Infowall file.(An earlier synthesis pass wrongly reported Infowall as "out-of-tree/unopenable"; that was a tooling artifact — it searched inside this workspace instead of the absolute path. Corrected throughout. The Infowall internals cited here came from agents that did read the real source.)
Code referenced (verified live):
packages/core/src/plugins/{mount,wire,cron,capabilities}.ts,plugins/hooks/{catalog,typed-hooks}.ts,plugins/sdk/define-plugin.ts,plugins/plugin-validator.ts,services/email/,migrations/037_email_log.sql,app.ts(222–330),my-sonicjs-app/src/index.ts,packages/core/tsconfig.json.
Mark's Infowall fork is readable at /Users/lane/Dev/refs/infowall-ai-main (read-only; not built by this repo). So "match Infowall's behavior" is a verifiable acceptance criterion — open the cited file and diff the behavior. No phase is gated on availability. Two practices keep it clean:
- Every "port" task below is a self-contained spec (signature, inputs, outputs, error posture, named test) — buildable with zero Infowall access. The Infowall citation is verifiable provenance, not a dependency. This is deliberate: the canonical contract should be defined by the spec + tests in this repo, not by whatever a sibling checkout happens to contain at a given commit.
- Optional convenience: vendor a snapshot. So reviewers needn't have the sibling checkout, copy the ~10 Infowall SDK files (
plugins/sdk/*,services/{cron-registry,email-service-singleton,hook-system-singleton}.ts, the email reference) intoreference/infowall/(committed, not built). Nice-to-have, not a precondition.
Decision: build to the specs in §2/§3 (they are complete on their own); reviewers cross-check each port against /Users/lane/Dev/refs/infowall-ai-main. The single thing that genuinely needs Mark's sign-off before Phase 5d is the convergence direction (§7 OQ1), not reference availability.
Where each fork leads (SonicJS verified against this branch; Infowall against /Users/lane/Dev/refs/infowall-ai-main)
| Dimension | Leader | One-line reason |
|---|---|---|
| Authoring API | mixed | Infowall: const-generic Caps narrows ctx at compile time + a real plugin migrated. SonicJS: transform-factory with strict-sync register, transform-capable handlers, lifecycle hooks. |
| Registration / two-phase boot | mixed | SonicJS: promise-memoized once-guard (concurrency-safe, wire.ts:124-131), per-plugin error isolation. Infowall: topo-sort + cycle detection + REJECT validation (SonicJS dependencies is inert in the v3 path). |
| Hook system & dispatch | Infowall | Identical HookSystemImpl bus; Infowall has real production dispatch sites, SonicJS has ZERO outside tests — its typed catalog is inert. |
| Capabilities & security | Infowall | 3 enforcement layers + type narrowing. SonicJS: 1 runtime layer + warn-only validation, no hook-subscription gate (wire.ts:84-85). But the canonical capability vocabulary already lives in SonicJS (capabilities.ts:21-32) — see §2.4 / §3.2. |
| Cron / scheduled | Infowall | Infowall fires end-to-end + live reconciliation. SonicJS cron is inert (createScheduledHandler exists at cron.ts:168 but is never wired into a Worker entry). |
| Singleton services | SonicJS | Generic createServiceSingleton<T> + promise-memoized guard (no race). Infowall: hand-written near-duplicates + boolean-after-await race + a warmup-fetch hack. |
| Email reference impl | mixed | SonicJS: provider-agnostic EmailProvider (Resend/SendGrid/Console) + safe degrade + email_log that already carries the status spine. Infowall: CF-binding-locked but has the live reconciler. |
| Testing & DX | mixed | SonicJS: type-level __typeChecks, cron-dispatch glue tested, once-guard concurrency tested. Infowall: blessed mock-factories.ts, real-route dispatch tests, a migrated reference plugin. |
| Code structure / type identity | mixed | SonicJS: structural contracts bridge the src/dist dual-Plugin identity (but erode types to any) + commits dist/. Infowall: single SonicPlugin<Caps>, gitignored dist. |
| Production-readiness / future-proofing | mixed | Infowall: REJECT posture, live cron+reconciliation. SonicJS: error isolation, db:<table> ownership, provider-agnostic email, concurrency-safe guard. |
SonicJS is the base of record; we port Infowall's enforcement, dispatch, and ordering into it.
- SonicJS already shipped (#841/#842/#844) and is the live codebase here. Infowall is the reference to harvest from, not rebase onto.
- SonicJS owns the better substrate — promise-memoized once-guard, per-plugin error isolation, generic singleton factory, provider-agnostic email,
db:<table>ownership, the augmentable-interface catalog, strict-syncregistertyping, and the already-converged capability vocabulary. - Infowall owns the better rigor — it actually fires hooks, enforces capabilities at the hook-subscription layer, orders by dependency with cycle detection, and runs cron+reconciliation end-to-end.
The original plan called "contract alignment" the headline and lumped the capability vocabulary in with it. That is half wrong. The capability vocabulary is already converged on the SonicJS side: FIXED_CAPABILITIES (capabilities.ts:21-32) already ships media:read/write, http:fetch, cron:register, admin:menu, hooks.auth:subscribe, hooks.content:subscribe, plus the db:<table> pattern. There is no storage:* in SonicJS to rename. So the capability work is small: a one-directional Infowall→canonical rename map + flipping warn→reject.
The real long pole is three things, all on the SonicJS side:
- Hook catalog + real dispatch — zero production dispatch sites; the catalog is inert (§2.3, §3.3).
- Cron + wire-phase reachability from
scheduled()— not just email-init; the entire wire phase is HTTP-gated and never runs in the scheduled path (§2.5, §2.6). - Capability enforcement at the hook-subscription boundary — the confirmed security regression (§2.4).
Contract alignment still goes first (do it in #844 so nothing is built against shapes that change), but it is the hook catalog/payloads, not the capability vocab, that carries the weight.
Each decision is final and grounded in verified SonicJS source. "Port" = bring Infowall's mechanism into the named SonicJS files, implemented to the self-contained spec below and cross-checked against the cited Infowall file at /Users/lane/Dev/refs/infowall-ai-main.
2.1 Authoring API → Keep SonicJS's transforming definePlugin; ADD const-generic Caps and a declarative hooks map
Winner: merge, SonicJS shape wins. definePlugin(input): DefinedPlugin (sdk/define-plugin.ts:166) is the base — it throws on missing id/version, enriches onBoot/onCronTick with a typed+gated context, carries lifecycle hooks (install/uninstall/activate/deactivate), and emits __sonicV3. Keep all of it.
Port (self-contained spec; cross-checkable against infowall-ai-main/.../sdk/define-plugin.ts):
- Const-generic capability narrowing. Re-type as
definePlugin<const Caps extends readonly Capability[]>(input: DefinePluginInput<Caps>): DefinedPlugin<Caps>soctx.cap.emailresolves toEmailService | neverinstead of today'sunknown(capabilities.ts:109-113). Pure DX shift-left; no runtime change. Keep SonicJS's runtime lazy getters underneath as defense-in-depth.- Test:
@ts-expect-errorwhen readingctx.cap.emailwithout'email:send'inCaps; positive case resolves toEmailService.
- Test:
- Declarative
hooksfield onDefinePluginInput. Addhooks?: { [E in HookEventName]?: TypedHookHandler<E> }. The runtime already supports declarativehooks[]viaWirablePlugin.hooks+wireRegisteredPluginsPhase A (wire.ts:75-91); this is surfacing a typed field and threading it into the existing array, not a new engine. Effort: LOW. Keep imperativectx.hooks.on()inonBootas the dynamic-subscription escape hatch.
Keep SonicJS-strictly-better: register?: (app: Hono) => void typed strictly synchronous (define-plugin.ts:88); the strict-sync guard (PluginRegisterMustBeSyncError) is correct. Keep transform-capable handlers (TypedHookHandler returns HookPayload<E> | void, void-coalesced at typed-hooks.ts:79) — pub/sub-only handlers cannot gate/mutate writes.
2.2 Registration & two-phase boot → Keep SonicJS's structure; PORT topo-sort + cycle detection; ADD opt-in strict mode
Winner: merge, SonicJS structure wins. Keep the mount.ts (sync construction) / wire.ts (async lazy) split, per-plugin error isolation (result.skipped, result.errors, "wiring never throws"), and the strict-sync register guard (mount.ts:63).
Take SonicJS's promise-memoized once-guard wholesale (createPluginWirer caches the in-flight promise, wire.ts:124-131). This is the correct fix for Infowall's boolean-set-after-await race. Do NOT regress to a boolean guard.
Port — dependency ordering (self-contained spec):
- Add
topoSort(plugins): DFS with avisiting-Set for cycle detection, returning a dependency-first ordering, throwingPluginDependencyCycleError(new, exported) on a cycle. Operate on the structuralMountablePlugin/WirablePlugincontracts so it stays cast-free. Signature:topoSort(plugins: ReadonlyArray<{ id: string; dependencies?: string[] }>): typeof plugins. - Make
DefinedPlugin.dependenciesactually drive ordering. Today it is inert — neithermount.tsnorwire.tsreads it (verified:magic-link-auth'sdependencies: ['email']is convention-only). WiretopoSortinto both mount order and wire order.- Test:
[B(deps:[A]), A]wiresAbeforeB;[A(deps:[B]), B(deps:[A])]throwsPluginDependencyCycleError; a missing dependency id is a strict-mode reject / prod warn.
- Test:
Add opt-in strict mode (dev/CI): a strict flag surfacing capability_missing / unknown-capability / cycle / bad-semver as throws at registration, with resilient per-plugin capture as the production default. Posture is a deliberate, configurable decision — never an accident of which code was copied.
2.3 Hook system & event catalog → Keep HookSystemImpl; KEEP SonicJS's typed catalog; PORT real dispatch sites (highest-value action)
Winner: Infowall on dispatch reality; SonicJS on typing. The bus (HookSystemImpl) is the convergence anchor; no work there.
Keep SonicJS's catalog representation: interface HookEventPayloads (catalog.ts:54-67) + the satisfies-guarded HOOK_EVENT_NAMES runtime list (catalog.ts:85-95) + declaration-merging extensibility. Strictly better-typed than Infowall's {type, [key]:unknown}.
THE decisive action — instrument real dispatch sites. Verified: SonicJS's v3 hook surface is inert — zero dispatch/execute of any content:*/auth:* catalog event outside tests; production auth/email call getEmailService().send() directly. A v3 plugin subscribing to content:create or auth:registration:completed never fires today. Port (additively):
- Auth (fire-and-forget via
executionCtx.waitUntil):auth:registration:completed,auth:password-reset:requested,auth:password-reset:completedinroutes/auth.ts;auth:magic-link:consumed,auth:otp:verifiedin their plugin routes. - Content (before/after split): in the content service /
api-content-crudwrite paths.
Add dispatch sites as a thin additive layer alongside the existing working direct sends (lower risk than ripping out direct calls), then migrate consumers to events.
⚠ This is a breaking change to the shipped catalog, not greenfield. catalog.ts already ships content:create/update/delete/read/publish/save (no before/after), and wire-integration.test.ts:62 dispatches content:create and asserts a subscriber fires. Renaming to content:after:create + dropping content:save breaks that shipped catalog and its passing test. Therefore (see §3.3):
- Treat the rename as a breaking catalog change with a one-release alias window (
content:createaliasescontent:after:create, emitting a deprecation warn on subscribe). - Update
wire-integration.test.tsexpectations in the same change, and rewrite it to drive a real route rather than a manualdispatch(so it certifies the production flow, not just the bus).
Transform contract: KEEP SonicJS's mutate-and-return. Required for before-hooks to gate/mutate writes; already tested.
2.4 Capabilities & security → Keep SonicJS's already-canonical vocab + db:<table> + lazy getters; PORT hook-subscription enforcement + type narrowing (the real gap)
Winner: Infowall on enforcement; SonicJS owns the vocabulary AND the base. Correcting the original plan: SonicJS's runtime lazy gated getters (createCapabilityContext, capabilities.ts:127-158), its db:<table> ownership, and its capability names (FIXED_CAPABILITIES, capabilities.ts:21-32) all stay. The vocabulary is not a future merge target — it already is the canonical set.
Port the enforcement (SonicJS's confirmed security regression):
- Hook-subscription gating — the one true security gap.
wire.ts:84-85registers every declared hook with no capability check;hooks.auth:subscribe/hooks.content:subscribegate nothing. Build aHOOK_CAPABILITY_MAP: Record<HookEventName, Capability>mapping each (now-unified) event name to its required cap, and reject-on-miss inwire.tsPhase A. Caveat (verified): SonicJS subscribes by arbitraryhook.namestring with no closed-catalog check at the wire boundary, so closing the gap requires the unified event names from §3.3 to land first; the map keys offHookEventName, not free strings.- Test: a plugin declaring
hooks.content:subscribe-less but subscribingcontent:after:createis rejected withSonicCapabilityError; declaring the cap allows it.
- Test: a plugin declaring
- Validation → throw (strict). Upgrade
definePlugin's warn-only unknown-capability check (define-plugin.ts:172-178, "These will gate nothing") to a hard fail in strict mode.validateCapabilitiesalready returns the unknown set (capabilities.ts:84-86); only the caller's disposition changes. - Type-level narrowing per §2.1.
Reconcile SonicCapabilityError — and version it as breaking. Current shape (capabilities.ts:52-63) is { capability, plugin }. Adding accessedApi and applying it both sides is a breaking change to an exported error (anyone catching/inspecting it). Ship the new shape { capability, plugin, accessedApi? } with accessedApi optional so existing consumers don't break, document it in the changelog as a shape change, and keep the existing fields' names unchanged.
Shared immaturity (track, don't per-fork): only email is operationally wired; media/cache/http/cron providers are unwired; db:/media:/admin:menu/hooks.* gate nothing at runtime beyond subscription; ambient c.env is ungated; read/write granularity is cosmetic (:read still grants .put). Follow-ups, not blockers.
Drop or define request:intercept (was kept blindly). SonicJS has no middleware-plugin mechanism — plugins mount routes (mount.ts) and register(app) synchronously; there is no capability-gated middleware-insertion surface. Keeping request:intercept would gate nothing, repeating the exact anti-pattern this plan condemns. Decision: DROP request:intercept from the canonical vocabulary until a real middleware-insertion surface exists; reintroduce it together with that surface (tracked in §5.4 Phase 2).
2.5 Cron → Keep registry-free direct-dispatch; WIRE the entry; make the wire phase reachable from scheduled(); enforce cron:register; per-provider reconciliation
Winner: Infowall on liveness; SonicJS's model is cleaner. Keep dispatchCronTick over the plugin LIST (cron.ts:111-136) and createScheduledHandler (cron.ts:168) — no CronRegistry, no cold-isolate empty-registry, no warmup fetch. Do NOT add cron:tick to the typed catalog.
Cron action #1 — wire the Worker entry. Verified inert: my-sonicjs-app/src/index.ts:56 is export default app (plain Hono, no scheduled:); no [triggers] in wrangler.toml; no plugin declares crons[].
createSonicJSAppmust expose the resolved plugin list (today private:corePluginsBeforeCatchAll+corePluginsAfterCatchAll+config.plugins.register,app.ts:233-253).- The reference app hand-assembles its outer Hono (
index.ts:44-54,app.route('/', coreApp)), so the export{ fetch: app.fetch, scheduled: ... }goes on that entry.
Cron action #2 — make the wire phase reachable from scheduled() (the bigger, previously-missed gap). Verified: wirePlugins() runs only inside app.use('*') (app.ts:314) and initEmailService runs there too (app.ts:319). The scheduled path has no c.env, no middleware chain — so in a cron-first cold isolate, plugin wiring never runs, onBoot never ran, and getHooks() returns an empty bus. A plugin whose onCronTick dispatches a hook fires into a void, and getEmailService() throws-before-get. Fix:
- Extract
initEmailServiceand the wire trigger out of thecreateSonicJSAppclosure into exported, env-only functions — this is a real refactor, not a one-liner (both are currently closures atapp.ts:269andapp.ts:252, neither exported, neither reachable fromcron.ts). Target API:export async function bootIsolate(env, { hooks, plugins, config }): Promise<void>that runs the once-guarded wire pass +initEmailService(env)(and bootstrap/migrations if the cron path needs DB).
createScheduledHandlercallsbootIsolate(env, …)beforedispatchCronTick, sharing the same promise-memoized once-guard the HTTP path uses, so a warm isolate is a no-op and a cron-first isolate is correctly initialized.- Test: a fresh isolate whose first event is
scheduled()has a populated hook bus and a reachablegetEmailService(); the HTTP path remains a no-op second time.
- Test: a fresh isolate whose first event is
[triggers] is deploy-time static — collectCronSchedules() is an OFFLINE codegen step, not runtime. Verified constraint: Cloudflare wrangler.toml [triggers] crons cannot be generated from runtime state on Workers. So:
- Ship
collectCronSchedules()as a sync command that reads the app config offline and writeswrangler.toml[triggers](codegen), with a CI check that the committed[triggers]matches the configured plugins'crons[]. The plan does not pretend triggers can be dynamic.
Enforce cron:register at definePlugin/registration — reject a crons[]/onCronTick declaration when cron:register is absent. Add 5-field cron-expression validation at declaration time (both forks silently no-op on malformed schedules). Do NOT inherit Infowall's unconditional legacy-workload firing (no event.cron guard).
Reconciliation is net-new and per-provider. Infowall's reconciler is CF-GraphQL-specific and does NOT port to Resend/SendGrid. Add optional reconcile(rows) on EmailProvider + a core email-reconciliation cron (via crons[]+onCronTick) to populate delivery_state (migration 037, always null today).
2.6 Singleton services → Keep the generic factory; SOLVE cron-first-isolate reachability via the extracted boot fn; close DI #561 as won't-fix
Winner: SonicJS. Adopt createServiceSingleton<T>(label) as the shared base for ALL singletons (throw-before-get + idempotent-last-write-wins + reset, test-proven), retiring Infowall's two hand-written files.
Solve cron-first-isolate reachability — see §2.5's bootIsolate extraction. The original "have createScheduledHandler call initEmailService(env)" is infeasible as written: initEmailService is a closure inside createSonicJSApp (app.ts:269), not exported, not reachable from the standalone createScheduledHandler factory (cron.ts:168). The fix is the extraction refactor in §2.5, and it must also run the wire pass, not just email init.
Close DI #561 as "won't fix — superseded by the env-independent factory." The env-independent access requirement (cron runs outside any request) is inherent to Cloudflare's per-request-binding model; the generic factory makes it a clean, testable seam. Keeping the factory while leaving #561 "delete singletons" open is contradictory dead work.
Tighten SonicJS's initEmailService to share the wiring promise rather than relying on the benign hasEmailService() double-run window (app.ts:270).
2.7 Email reference impl → Keep the provider-agnostic core; ADD CF as a 4th provider; the status spine ALREADY exists; fix the DB-settings bypass
Winner: mixed, SonicJS transport model wins. Keep EmailProvider { name; isConfigured(); send(NormalizedEmailMessage) } with Resend/SendGrid/Console + safe degrade-to-Console (resolve-provider.ts:73-81). Add a fourth CloudflareEmailProvider (CF send_email binding) so CF is one transport among many.
Correction: the two-column status spine is ALREADY in SonicJS. Verified 037_email_log.sql already has status, delivery_state, delivery_synced_at, failed_at_send, flow, provider, provider_id. So "port the spine" is already done. The genuinely missing columns are only user_id, context_type/context_id, tenant_id, and partial indexes (§3.4). Do not re-derive existing columns.
Fix the SonicJS DB-settings bypass: app.ts:289 hardcodes new ResendProvider(...) instead of routing through resolveEmailProvider, so the admin-UI path is Resend-locked and skips the degrade-to-Console safety. Route through resolveEmailProvider and apply DbEmailSettings.replyTo as a default (dropped today).
Package SonicJS email as a definePlugin() plugin to prove the v3 authoring story. Today the registered emailPlugin is still the legacy PluginBuilder admin-UI plugin whose /test calls fetch('https://api.resend.com/emails') directly.
Keep both leak-closures (they converge on outcome): SonicJS closes #574 via direct-call (auth.ts, never returns the link). Once auth events fire (§2.3), password-reset email can move to the event path without reopening the leak.
2.8 Testing & DX → Union the suites; ADD a shared mock harness; close the two correctness gaps; concrete catalog-coverage gate
Winner: mixed. Each fork's tested layer is the other's blind spot.
- SonicJS contributes: cron-dispatch glue, type-level
__typeChecks()with@ts-expect-error, once-guard concurrency (Promise.all→bootCount===1), provider/degrade tests. - Infowall contributes: real-route dispatch tests that exercise a live emitter, and the blessed
mock-factories.ts(makeMockD1Database/makeMockKVNamespace/makeMockHonoContext/makeMockEmailService/makeMockHookSystem).
Build a single published author-facing mock harness (port mock-factories.ts into SonicJS __tests__/utils/). 5+ inline hook-system fakes already exist; without one harness, third-party authors have no importable test primitive.
Close the two correctness gaps, both test-gated:
- (a) Add production dispatch sites (§2.3) so the green wire/typed-hooks tests stop certifying a dead API. Rewrite
wire-integration.test.tsto drive a real route, not a manualdispatch— today (:62) it dispatches the event itself, certifying the bus, not the production flow (false confidence on Risk #2). - (b) The once-guard fix is already SonicJS's.
Make the catalog-coverage rule a concrete, named deliverable (it's cheap and prevents the exact "inert but green" regression): a test no-event-without-dispatch-site.test.ts that, for every name in HOOK_EVENT_NAMES, asserts a matching dispatch('<name>' source occurrence exists outside __tests__. Specify it now; don't leave it a vague checkbox.
Add ONE v3-SDK e2e and make my-sonicjs-app dogfood plugins.register + a scheduled handler — it currently bypasses the framework (autoLoad:false, hand-loops contactFormPlugin.routes, wraps coreApp; verified index.ts:40-54).
2.9 Code structure & type identity → Stop committing dist/; fix the self-import identity at BOTH compile-time and runtime
Winner: mixed. SonicJS's structural contracts (MountablePlugin/WirablePlugin/CronablePlugin/HookSystemLike) bridge the src/dist dual-Plugin identity but erode types to any (mount.ts:53, typed-hooks.ts:25-27).
Fix the root cause — do these together:
- Stop committing
dist/. Gitignorepackages/*/dist/. The committeddist+ dirty tree (40 D / 24 M / 40 untracked) is actively misleading. - Fix the identity split at BOTH halves (the original plan fixed only the compile-time half). Core plugins self-import
import { Plugin } from '@sonicjs-cms/core', resolving via thenode_modulessymlink topackage.jsontypes: ./dist/index.d.tswhile runtime modules live insrc.- Compile-time: add to
packages/core/tsconfig.jsonpaths(which already maps@sonicjs-cms/templates, verified:12-15) →"@sonicjs-cms/core": ["./src/index.ts"],"@sonicjs-cms/core/*": ["./src/*"]. Risk flagged: the barrelsrc/index.tscould self-resolve@sonicjs-cms/coreback to itself; verify no import cycle through the barrel (prefer mapping the deepsrc/*paths and converting the handful of core-plugin self-imports to relative../../imports, which sidesteps the barrel entirely — Infowall importsdefinePluginvia relative../../sdk). - Runtime (the missing half): path-mapping only affects typecheck; the runtime still loads
distvia the symlink and is stale if not rebuilt. Add aprebuild/watch guarantee and a test that imports through the package entry and assertsPluginidentity at runtime. Gitignoringdist+ path-mapping alone dissolves only the compile-time half. - Test:
instanceof/structural identity check importing the same symbol via the package entry and viasrcresolve to one identity; a CI step fails ifdistis older thansrc.
- Compile-time: add to
Then tighten the contracts. Once the identity split is gone the structural contracts become optional; keep them (cast-free user plugins) but tighten any/unknown toward typed Hono<{ Bindings; Variables }> signatures.
2.10 Production-readiness & future-proofing → Sequence: vocabulary/payloads → real dispatch → enforcement → cron/reconciliation+DB reflection → schema reconciliation
The one net-new item beyond prior dimensions: reflect v3 registration into the DB plugins table (best-effort, non-fatal) so the admin view (admin-plugins.ts) stops drifting from the actually-wired config.plugins.register list.
Until this lands, every plugin is fork-specific and we do not advertise cross-compatibility. Two artifacts; note the asymmetry corrected from the original plan.
- Capabilities — mostly already converged. SonicJS's
FIXED_CAPABILITIESalready IS the canonical set. The only work is a one-directional Infowall→canonical rename map + flipping warn→reject. This is not the long pole. - Hook events — the real long pole. Names differ, payload shapes are internally inconsistent even within SonicJS (see below), and dispatch reality differs (Infowall fires; SonicJS fires nothing). This carries the weight.
Decision: SonicJS's shipped vocabulary IS canonical. Add a one-directional CAPABILITY_RENAMES (Infowall→canonical) + flip the unknown-capability posture from warn to reject. No SonicJS rename is needed — there is no storage:* here.
| Canonical (already in SonicJS) | Infowall spelling (migrates via rename map) | Note |
|---|---|---|
email:send |
email:send |
identical |
cache:read / cache:write |
same | identical |
media:read / media:write |
storage:read / storage:write |
canonical already SonicJS; rename Infowall in |
http:fetch |
(none) | SonicJS-only; gate egress |
db:<table> |
(none) | SonicJS-only; keep /^db:[a-zA-Z_][a-zA-Z0-9_]*$/ |
admin:menu |
(none) | SonicJS-only |
cron:register |
hooks.cron:register |
cron is a direct method, not a subscription |
hooks.auth:subscribe |
hooks.auth:register |
:subscribe matches pub/sub |
hooks.content:subscribe |
hooks.content-write:register + hooks.content-read:register |
one cap; before/after granularity lives in the event |
hooks.email:subscribe (add when email events ship) |
hooks.email-events:register |
follows the hooks.<family>:subscribe rule |
request:intercept |
request:intercept |
DROPPED (§2.4) — no middleware surface to gate |
CAPABILITY_RENAMES seed (one-directional, Infowall→canonical): storage:read→media:read, storage:write→media:write, hooks.cron:register→cron:register, hooks.auth:register→hooks.auth:subscribe, hooks.content-write:register→hooks.content:subscribe, hooks.content-read:register→hooks.content:subscribe, hooks.email-events:register→hooks.email:subscribe. Suffix rule: hooks.<family>:subscribe for subscription caps; <service>:<verb> for service caps.
Enforcement: unknown cap → hard reject (strict) / loud warn (prod), never silent. Implement in validateCapabilities callers + the HOOK_CAPABILITY_MAP (§2.4). The media:read/media:write/http:fetch/cron:register/admin:menu/hooks.auth:subscribe/hooks.content:subscribe rows are "already canonical, no SonicJS change."
Decision: SonicJS's augmentable interface HookEventPayloads + satisfies-guarded runtime list, RE-KEYED to before/after content + a SINGLE unified actor shape, pruned to events with a real dispatch site. This is a breaking change to the shipped catalog (content:create→content:after:create, drop content:save), shipped with a one-release alias window and updated tests (§2.3).
FIX the actor-shape inconsistency (verified internal bug). Today the shapes disagree within SonicJS:
- content payload:
user?: { userId: string; email; role }(flat,catalog.ts:25) — usesuserId. - auth payloads:
user: { id: string; email; role }(catalog.ts:29-31) — usesid.
A plugin author reading user.id on content or user.userId on auth gets undefined. Decision: ONE canonical actor shape across all events — user?: { id: string; email: string; role?: string } (use id, not userId). Re-key the content payload from userId→id as part of #844a; this is itself a breaking field rename and rides the same alias/deprecation window.
| Concept | Canonical | Was (SonicJS) |
|---|---|---|
| Pre-create (gate/transform) | content:before:create |
(none) |
| Post-create | content:after:create |
content:create (alias 1 release) |
| Pre/Post-update | content:before:update / content:after:update |
content:update (alias) |
| Pre/Post-delete | content:before:delete / content:after:delete |
content:delete (alias) |
| Read | content:read |
content:read |
| Publish | content:after:publish |
content:publish (alias) |
| Save | DROP | content:save (deprecate; covered by create/update) |
| Registration done | auth:registration:completed |
same |
| PW reset requested | auth:password-reset:requested { user:{id,email}, resetToken } |
same |
| PW reset completed | auth:password-reset:completed |
same |
| Magic-link consumed | auth:magic-link:consumed |
(none) |
| OTP verified | auth:otp:verified |
(none) |
Payload rule: unified user?: { id, email, role? } everywhere. Content keeps the concrete ContentEventPayload { collection, id?, data, user? } (with user.id, not userId).
Cron is NOT a catalog event (§2.5). Prune the dead — every catalog event must have a real dispatch site landing in the same change.
Correction (verified against live 037): delivery_state, delivery_synced_at, failed_at_send, flow, provider, provider_id are already present. The original plan's "add failed_at_send (already present)" was self-contradicting. The migration adds 4 columns + partial indexes, nothing else.
| Concept | In 037 today |
Action |
|---|---|---|
Submit status status {pending,sent,failed} |
✅ present | keep |
failed_at_send epoch |
✅ present | no-op (was wrongly listed as "add") |
delivery_state / delivery_synced_at |
✅ present (null) | populate via per-provider reconcile (§2.5) |
flow / provider / provider_id |
✅ present | keep |
user_id |
❌ | ADD |
context_type / context_id |
❌ | ADD |
tenant_id |
❌ | ADD |
| Partial indexes (status/synced-at hot paths) | ❌ (5 plain indexes exist) | ADD partial indexes |
Migration numbering: highest today is 037; 038 is free now, but this plan spans phases during which other migrations may land. Use "the next free number at implementation time," not a hardcoded 038, and add a CI guard for duplicate migration prefixes (none exists today). Name it <NN>_email_log_observability.sql.
Rollback / forward-only story (D1): D1 migrations are forward-only on Workers. The 4 new columns are nullable with no default, so a half-applied migration leaves existing rows valid (NULLs) and in-flight writes unaffected — writeLog stays best-effort and never references the new columns until the migration is confirmed applied (gated by the bootstrap migration runner). No backfill is required; observability of pre-migration rows degrades gracefully to NULL. Document this explicitly in the migration header.
Before third parties build plugins, ALL must be true. Each is test-gated.
-
auth:registration:completed,auth:password-reset:requested,auth:password-reset:completeddispatched fromroutes/auth.tsviaexecutionCtx.waitUntil(never blocks the response). -
auth:magic-link:consumed,auth:otp:verifiedfrom their plugin routes. -
content:before:*/content:after:*/content:readfrom the content write/read paths. - Real-route integration test (rewritten
wire-integration.test.ts) drives an actual HTTP route → subscriber fires; it no longer manuallydispatches. - Named CI test
no-event-without-dispatch-site.test.ts: everyHOOK_EVENT_NAMESentry has a non-test dispatch site (the concrete remediation for Risk #2).
- Production default: per-plugin isolation preserved (
mount.tsskip,wire.tscapture,cron.tsisolate — never throw). - Strict mode (dev/CI): unknown-capability,
capability_missingon hook subscription, dependency cycle, bad semver all throw at registration. -
register()async return is a hard error (already:PluginRegisterMustBeSyncError). - Only SonicJS's promise-memoized
createPluginWirerguard is used — no boolean-after-await.
- Migration
<NN>_email_log_observability.sqladdsuser_id,context_type/id,tenant_id+ partial indexes (existing columns untouched); forward-only/NULL-safe documented. - CI guard: no duplicate migration prefixes.
- Admin
email_logbrowser shows submit-sidestatus+ delivery-sidedelivery_state. - Per-provider
reconcile()populatesdelivery_state. -
writeLogstays best-effort (logging failure never fails the send) and never references new columns pre-migration.
- Hook-subscription gating live (
HOOK_CAPABILITY_MAPkeyed byHookEventName+ reject) — closes the regression. - Unknown caps rejected (strict) / loudly warned (prod) — never silent;
CAPABILITY_RENAMESapplied on load. -
SonicCapabilityErrorshape{ capability, plugin, accessedApi? }(accessedApioptional → non-breaking), changelogged. - Type narrowing live:
ctx.cap.emailisEmailService | never, notunknown. -
request:interceptis NOT in the vocabulary until a middleware surface exists. - Documented honest boundary: ambient
c.envand raw Honoappare NOT capability-gated.
-
scheduled:handler wired in the reference Worker entry; resolved plugin list exposed fromcreateSonicJSApp. -
bootIsolate(env, …)(extracted from thecreateSonicJSAppclosure) runs the once-guarded wire pass + email init BEFOREdispatchCronTick— verified test: cron-first cold isolate has a populated hook bus and reachablegetEmailService(). -
collectCronSchedules()is an offline codegen sync command writingwrangler.toml[triggers]; CI checks committed triggers match config. ([triggers]is acknowledged deploy-time static.) -
cron:registerenforced; 5-field expression validated at declaration; no unconditional legacy-workload firing. - Cron integration test: fired trigger →
bootIsolate→onCronTick→ email/reconcile end-to-end.
-
my-sonicjs-appmigrated toplugins.register+definePlugin+scheduled(dogfoods the framework). - Shared
mock-factories.tspublished for third-party authors. - One v3-SDK e2e: "drop a plugin into
plugins.register→ routes + hooks + cron work."
- Plugins ship as npm packages exporting a
definePlugin(...)default; consumers import and add toplugins.register: []. No filesystem auto-loading (Workers has no runtimefs; today'sdirectory/autoLoadis deprecated and should be removed, not extended). - Build-time registry generator: a script that reads installed plugin packages and emits a static, tree-shakeable
plugin-registry.tsimport map, replacing the legacy manifest-registry the admin reads. definePluginoutput is already npm-portable (__sonicV3marker for detection).
- Connect the existing semver gate — but reconcile the field-name mismatch first (verified).
plugin-validator.ts:374/394runssemver.satisfies/intersectsagainst the legacyPlugin.compatibilityfield, whichDefinePluginInputdoes not have. So you cannot just "connect the existing gate." AddsonicjsVersionRange?: stringtoDefinePluginInput, and either (a) mapsonicjsVersionRange→the validator'scompatibilitykey at the boundary, or (b) generalize the validator to read whichever field is present. Validate at registration against the running core version. - Add explicit
semver.valid()reject for the plugin's ownversion.
- The reconciled vocabulary (§3) is the precondition. "Verified plugin" badge requires: caps all known + gated, all subscribed events in the catalog, version-range satisfied, no dependency cycle.
- DB-reflected activation (§2.10) gives the admin UI a single source of truth.
- Phase 1 boundary: the curated capability-gated service surface (email/cache/http/media/db). Documented as NOT covering ambient
c.env/rawapp. - Phase 2 (deferred, tracked): method-level read/write enforcement (today
:readstill grants.put), egress gating wired tohttp:fetch, a decision on hiding rawenv, and a real middleware-insertion surface that finally makesrequest:interceptenforceable (only then is that cap reintroduced).
- Resolved per §2.9: gitignore
dist/+ self path-mapping (compile-time) + a runtime identity test + prebuild/watch guarantee (runtime). Path-mapping alone dissolves only the compile-time half.
- Both forks still ship
plugins/sdk/plugin-builder.ts. SonicJS core plugins (email,hello-world,workflow) still author via it. - Migrate core plugins to
definePluginone-by-one (start withemail, §2.7), keepPluginBuilderas a thin shim that internally callsdefinePlugin, then remove it once no core plugin depends on it. Do NOT remove until the migration is complete (workflow-plugin/index.tsstill uses it).
Each phase is test-gated (keep the core suite green + add listed tests) and tsc-clean. Ported behavior is cross-checked against the Infowall reference at /Users/lane/Dev/refs/infowall-ai-main. The only cross-team prerequisite before Phase 5d is Mark's sign-off on the convergence direction (§7 OQ1) and the contract changes (§3).
- #844a — Contract alignment (§3): land the
CAPABILITY_RENAMESmap (Infowall→canonical; SonicJS vocab unchanged) + flip warn→reject posture; re-key the hook catalog to before/after with a one-release alias window, dropcontent:save, unify the actor shape touser.ideverywhere (fixes theuserId/idsplit), and update + real-route-ifywire-integration.test.tsin the same change. Tests: catalog__typeChecks, capability validation matrix, rename-map resolution, alias-deprecation warn, actor-shape consistency. - #844b — definePlugin ergonomics: const-generic
Capsnarrowing + declarativehooksfield threaded into existinghooks[]. Tests:ctx.cap.emailisEmailService|never; declarative hook fires through wiring. - #844c — Fix the email DB-settings bypass (route
app.ts:289throughresolveEmailProvider, applyreplyTo). Tests: admin-UI SendGrid selection + degrade-to-Console.
- Instrument real dispatch sites (auth + content) additively (§2.3, §4.1), honoring the alias window.
- Build
HOOK_CAPABILITY_MAPkeyed byHookEventName+ reject-on-miss inwire.tsPhase A (§2.4). - Upgrade unknown-capability to strict-reject; ship
SonicCapabilityErrorwith optionalaccessedApi(non-breaking). - Add the
no-event-without-dispatch-siteCI test. - Gate: real-route integration test proving a
definePluginsubscriber fires; security test proving an undeclaredhooks.auth:subscribeis rejected.
- Port
topoSort+ cycle detection; makedependenciesdrive order; addPluginDependencyCycleError(§2.2). - Extract
bootIsolate(env, …)out of thecreateSonicJSAppclosure (wire pass + email init), wirecreateScheduledHandlerto call it before dispatch; expose the resolved plugin list; cron-first-isolate reachability test (§2.5/§2.6). - Wire
scheduled:into the reference Worker entry; shipcollectCronSchedules()as an offlinewrangler.toml[triggers]codegen + CI parity check; enforcecron:register; 5-field validation. - Add
EmailProvider.reconcile()+ a coreemail-reconciliationcron; migration<NN>_email_log_observability.sql(4 cols + partial indexes only); migration-prefix CI guard (§3.4). - Add
CloudflareEmailProvider; package email as adefinePluginplugin (§2.7). - Gate: cron end-to-end integration test (incl. cron-first isolate); dependency-cycle throws; reconciliation populates
delivery_state.
- Gitignore
dist/; self path-mapping (compile-time) + runtime identity test + prebuild/watch guarantee; verify no barrel self-cycle; tighten structural contracts (§2.9). - Build-time plugin-registry generator; reconcile
sonicjsVersionRangevs the validator'scompatibilityfield, then connect the semver gate into the v3 path (§5.1–5.2). - DB activation reflection in
wire.ts; adminemail_logbrowser; publishedmock-factories.ts; v3-SDK e2e; migratemy-sonicjs-appto dogfood the framework (§2.8, §2.10). - Begin legacy
PluginBuilderretirement (§5.6). - Gate: tsc-clean with
distuntracked; runtime identity test green; e2e green; admin view matches wired list.
- Breaking the shipped catalog (top risk). The before/after re-key +
content:savedrop + actor-shapeuserId→idrename are breaking changes to a shipped catalog and a passing test (wire-integration.test.ts:62), not greenfield. Requires the one-release alias window + same-change test updates (§2.3/§3.3). (The earlier draft listed "out-of-tree reference" as the top risk; that was a verification artifact — Infowall IS readable at/Users/lane/Dev/refs/infowall-ai-main, so every port has a verifiable reference and nothing is gated on availability.) - Convergence direction not yet agreed. This plan assumes SonicJS is base-of-record and Infowall rebases onto it — adopting SonicJS's name-map hook catalog (dropping Infowall's
type-discriminatedSonicHookEvent/SonicHookHandler<T>) and SonicJS's capability spellings. If Mark wants the reverse (or a different hook-API shape), §2.1/§2.3/§3 change materially. Resolve OQ1 before Phase 5d. - Inert-but-green API. SonicJS passes its suite while firing zero catalog events. The
no-event-without-dispatch-sitetest is the concrete fix. - Cron readiness is doubly overstated. Cron is inert AND the entire wire phase is HTTP-gated, so cron-first isolates have an empty hook bus and unreachable email — bigger than email-init.
bootIsolateextraction is a real refactor;[triggers]is deploy-time static (offline codegen only). - Reconciliation is non-portable. Infowall's CF-GraphQL reconciler can't be copied; per-provider
reconcile()is net-new;delivery_statestays null until it lands. - Type-erosion vs strict typing. Tightening structural contracts may surface latent
any-masked mismatches; expect a tsc cleanup pass. Runtime identity is fixed separately from compile-time. - Reference-app drift.
my-sonicjs-appbypasses the framework; breaking changes won't surface until Phase 7's dogfooding migration. - Cron vs first-request concurrency (NEW). A cron and a first HTTP request can run in separate isolates against shared D1. Migration/seed idempotency is the only coordinator. Decision: out of scope for app code — D1's
CREATE TABLE IF NOT EXISTS+ idempotent seeds handle it; the once-guardedbootIsolateis per-isolate, not cross-isolate. Documented, not engineered further.
- Convergence direction (the one decision that gates everything). This plan assumes SonicJS is base-of-record and Infowall rebases onto it: adopt SonicJS's name-map
HookEventPayloadscatalog (and retire Infowall'stype-discriminatedSonicHookEvent+SonicHookHandler<T>API), adopt SonicJS's capability spellings via a one-directionalCAPABILITY_RENAMES, and harvest Infowall's enforcement/dispatch/ordering/cron into it. Confirm this direction — or, if you'd rather keep the discriminated-union hook API, say so now, because it reshapes §2.1, §2.3, and §3.3. (Reference availability is NOT a question — your source at/Users/lane/Dev/refs/infowall-ai-mainis readable and was used to ground this plan.) - Content event granularity + alias window: confirm before/after canonical and a one-release alias for
content:create→content:after:create. Re-key Infowall's content subscribers, or want a rename adapter on your side? - Actor shape: confirm unifying to
user.id(drop content'suserId) across all events. - Capability suffix: confirm adopting
hooks.<family>:subscribe+ the one-directionalCAPABILITY_RENAMES, since it flips Infowall's:registerspelling. (SonicJS vocab is unchanged.) storage:→media:: OK to rename Infowall'sstorage:*to the canonical SonicJSmedia:*?request:intercept: OK to DROP it until a real middleware-insertion surface exists (it currently gates nothing in SonicJS)?- DI #561: close as "won't fix — superseded by the env-independent
createServiceSingletonfactory"? - CF Email transport:
CloudflareEmailProvideras a first-class core provider, or in your fork's overlay? (CF-specific; most SonicJS users are Resend/SendGrid.) - Reconciliation ownership: do you contribute
CloudflareEmailProvider.reconcile()? Ship Resend/SendGrid reconcilers, or leave them no-op (delivery_state null) until those providers expose delivery webhooks? - Transform contract: confirm SonicJS's mutate-and-return handlers (over void-only pub/sub) as canonical — required for before-hooks to gate/transform writes; changes the handler signature your fork's plugins were written against.