* fix(forecasts): unwrap seed-contract envelope in canonical-key sim patcher
Production bug observed 2026-04-23 across both forecast worker services
(seed-forecasts-simulation + seed-forecasts-deep): every successful run
logs `[SimulationDecorations] Cannot patch canonical key — predictions
missing or not an array` and silently fails to write simulation
adjustments back to forecast:predictions:v2.
Root cause: PR #3097 (seed-contract envelope dual-write) wraps canonical
seed writes in `{_seed: {...}, data: {predictions: [...]}}` via runSeed.
The Lua patcher (_SIM_PATCH_LUA) and its JS test-path mirror both read
`payload.predictions` directly with no envelope unwrap, so they always
return 'MISSING' against the new shape — meeting the documented pattern
in the project's worldmonitor-seed-envelope-consumer-drift learning
(91 producers enveloped, private-helper consumers not migrated).
User-visible impact: ForecastPanel renders simulation-adjusted scores
only when a fast-path seed has touched a forecast since the bug landed;
deep-forecast and simulation re-scores never reach the canonical feed.
Fix:
- _SIM_PATCH_LUA detects envelope shape (`type(payload._seed) == 'table'
and type(payload.data) == 'table'`), reads `inner.predictions`, and
re-encodes preserving the wrapper so envelope shape persists across
patches. Legacy bare values still pass through unchanged.
- JS test path mirrors the same unwrap/rewrap.
- New test WD-20b locks the regression: enveloped store fixture, asserts
`_seed` wrapper preserved on write + inner predictions patched.
Also resolves the per-run `[seed-contract] forecast:predictions missing
fields: sourceVersion — required in PR 3` warning by passing
`sourceVersion: 'detectors+llm-pipeline'` to runSeed (PR 3 of the
seed-contract migration will start enforcing this; cheap to fix now).
Verified: typecheck (both tsconfigs) clean; lint 0 errors; test:data
6631/6631 green (forecast suite 309/309 incl new WD-20b); edge-functions
176/176 green; markdown + version-check clean.
* fix(forecasts): tighten JS envelope guard to match Lua's strict table check
PR #3348 review (P2):
JS test path used `!!published._seed` (any truthy value) while the Lua
script requires `type(payload._seed) == 'table'` (strict object check).
Asymmetry: a fixture with `_seed: true`, `_seed: 1`, or `_seed: 'string'`
would be treated as enveloped by JS and bare by Lua — meaning the JS
test mirror could silently miss real Lua regressions that bisect on
fixture shape, defeating the purpose of having a parity test path.
Tighten JS to require both `_seed` and `data` be plain objects (rejecting
truthy non-objects + arrays), matching Lua's `type() == 'table'` semantics
exactly.
New test WD-20c locks the parity: fixture with non-table `_seed` (string)
+ bare-shape `predictions` → must succeed via bare path, identical to
what Lua would do.
Verified: 6632/6632 tests pass; new WD-20c green.