Skip to content

Pre-deploy hardening: fix silent data-loss + undo regression, pin pnpm, add canary deploy#810

Merged
bpowers merged 11 commits into
mainfrom
fix/pre-deploy-blockers
Jun 28, 2026
Merged

Pre-deploy hardening: fix silent data-loss + undo regression, pin pnpm, add canary deploy#810
bpowers merged 11 commits into
mainfrom
fix/pre-deploy-blockers

Conversation

@bpowers

@bpowers bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner

Why

A pre-deploy audit of HEAD against the version currently running in production (ae68caa4, from 2022) found that the code is broadly safe to ship except for one silent data-loss bug and one undo regression, plus a deploy-mechanics gap. This PR fixes those and adds tooling to safely canary the deploy. Each commit passed the full pre-commit gate (Rust fmt/clippy/test, WASM build, TS lint/build/tsc/test, pysimlin).

What

1. core: round-trip variable compat to avoid silent data loss (blocker).
Model content is persisted as engine-serialized protobuf, and edits apply as full-replace upsert ops. datamodel.ts dropped several wire fields on the JSON round-trip, so editing any field of an affected variable silently discarded them on save: ACTIVE INITIAL (separate initialization equation), the Vensim EXCEPT default equation, per-element graphical functions, external-data references (GET DIRECT DATA/CONSTANTS/LOOKUPS), and module compat (canBeModuleInput/isPublic/dataSource). The ACTIVE INITIAL case silently corrupted real 2022 models' numerics on a routine edit. All are now round-tripped for Stock/Flow/Aux/Module; JSON key names were verified against the engine's json.rs, and an end-to-end test drives the real WASM engine through an upsert round-trip.

2. diagram: record undo history for canvas content and layout edits (high).
Create, delete, move, label-move, and flow/link attach were not undoable (only equation/table/module/rename/sim-spec edits recorded history), making accidental deletes unrecoverable. These six discrete edits now record exactly one history entry each via a recordHistory option on updateView; the per-frame viewport stream (pan/zoom/momentum) still records nothing, so a momentum flick can't evict real edits from the small undo buffer.

3. deploy: pin engines.pnpm in the staged server manifest.
The staged GAE deploy (PR #809) has never run against real gcloud. App Engine's Node buildpack reads engines.pnpm with precedence over packageManager/corepack, and an exact version short-circuits its registry resolution; without it the buildpack can fall back to its bundled pnpm and reject the frozen lockfile. The pin is derived from the same packageManager spec (single source of truth).

4. deploy: add a canary deploy script (pnpm deploy:canary).
Automates a --no-promote staged deploy and temporarily authorizes the canary's *.appspot.com host in Firebase authorizedDomains (read-modify-write PATCH that can't wipe the list) so the operator can exercise the real product, including Google login, before switching traffic. A --cleanup mode de-authorizes the host and deletes the version, but refuses to delete a version that is serving traffic (so cleanup after promotion can't take the site down). Uses the operator's own credentials, not the CI deploy SA, and never promotes traffic.

Testing

TDD throughout; each item was implemented by one subagent and independently adversarially reviewed by another before commit. Pure logic is unit-tested (datamodel round-trips, history recording, manifest generation, canary host/domain/traffic helpers), plus a real-engine end-to-end round-trip test for the data-loss fix. The canary script's gcloud/Firebase shell cannot be integration-tested without prod credentials and was reviewed by reading.

Residual findings (tracked separately, not in this PR)

The audit surfaced additional, lower-severity items being filed as issues: view-element fidelity dropped on view edits, a dangling link/flow reference making the editor uneditable, a stale chart shown as current after a failed re-sim, no SIGTERM/graceful-shutdown handler, the in-process render worker being an unbounded CPU/mem sink, CI deploy-gate gaps (node 22 vs prod node 24, no engines.node pin, non-frozen lockfile, staged path not exercised), and .app.prod.yaml missing the static-handler CSP/HSTS headers.

bpowers added 4 commits June 27, 2026 17:53
Model content is persisted as a protobuf the engine serializes, and edits
apply as full-replace upsert ops keyed by ident. datamodel.ts converts each
variable to and from that JSON, so any wire field it failed to read in and
write back out was silently dropped the moment a user edited the variable,
corrupting real models on a routine edit.

Round-trip the previously-dropped fields for Stock/Flow/Aux/Module: ACTIVE
INITIAL (compat.activeInitial), the Vensim EXCEPT default equation and its
has_except_default flag, per-element graphical functions and per-element
ACTIVE INITIAL, external-data references (compat.data_source), and module
compat (canBeModuleInput/isPublic/dataSource). The arrayed elements map now
carries per-element data instead of bare equation strings.

Editor.tsx built several upsert payloads by hand, dropping the same fields;
rebase them on the shared *ToJson serializers. JSON key names were verified
against the engine's json.rs serializer, and an end-to-end test drives the
real WASM engine to prove the fields survive an upsert round-trip.
Creating, deleting, moving, and connecting elements on the canvas were not
undoable. Those handlers commit through applyPatchOrReportError (which does
not refresh or record) and/or updateView (which recorded with
recordHistory:false), so only equation/table/module/rename/sim-spec edits --
which go through applyPatch/refreshFromEngine -- advanced the undo buffer.
This regressed the pre-refactor behavior where every create/delete/move was
individually undoable, and made accidental deletes unrecoverable.

Add a recordHistory option to ProjectController.updateView and pass it from
the six discrete-edit handlers (create, delete, element/label move, flow
attach, link attach). Each records exactly one entry capturing the engine
state after both the content patch and the view update. The per-frame
viewport stream (pan/zoom/momentum/resize) keeps going through
queueViewUpdate, which still records nothing, so a momentum flick cannot
evict real edits from the small (5-entry) undo buffer.

Update the now-stale generation/panel-keying invariants: discrete layout
edits bump projectGeneration and remount open detail panels, which is safe
because a canvas edit first blurs and commits the side-panel editor.
The staged GAE deploy generates a self-contained server package.json that
pins packageManager: pnpm@<version>. App Engine's Node buildpack reads
engines.pnpm and gives it precedence over packageManager/corepack, and an
exact version short-circuits its registry resolution; without engines.pnpm
the buildpack can fall back to its bundled pnpm and re-resolve or reject the
frozen lockfile, failing the first real staged deploy.

Derive engines.pnpm from the same packageManager spec (single source of
truth), stripping any +sha512 build suffix so the bare exact version takes
the buildpack's fast path. Preserve any engines the server already declared,
with the pnpm pin winning.
Testing a --no-promote canary end-to-end is blocked by Firebase OAuth: a
versioned *.appspot.com URL is not in Firebase's Authorized domains, so
signInWithRedirect rejects it and the operator cannot log in to exercise the
real product before switching traffic.

Add scripts/deploy-canary.mjs (pnpm deploy:canary): build + deploy the staged
server with --no-promote (without overriding the version name), discover that
version's URL via gcloud app browse, and add its host to the Identity Toolkit
authorizedDomains via a read-modify-write PATCH (updateMask=authorizedDomains)
that can never wipe the existing list. A --cleanup mode de-authorizes the host
and stops the version -- but refuses to stop a version that is currently
serving traffic, so running cleanup after promoting the canary cannot take the
site down. It uses the operator's own gcloud credentials (firebaseauth.admin),
deliberately not the CI deploy service account, and never promotes traffic.

The pure core (host/URL parsing, domain merge, arg parsing, traffic-share
lookup) is unit-tested; the gcloud/fetch shell is kept thin. Documented in
docs/dev/deploy.md.
@codecov

codecov Bot commented Jun 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.87%. Comparing base (acb763c) to head (a93d385).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #810      +/-   ##
==========================================
- Coverage   90.87%   90.87%   -0.01%     
==========================================
  Files         224      224              
  Lines      143133   143133              
==========================================
- Hits       130072   130070       -2     
- Misses      13061    13063       +2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review summary

I reviewed the four work streams in this PR (datamodel round-trip, undo recording, engines.pnpm pin, canary deploy script) against the engine's json.rs serializer, the controller, and the GAE buildpack semantics. No blocking issues found.

What I cross-checked

  • datamodel.ts round-trip: JSON key names (activeInitial, dataSource, hasExceptDefault, kind/file/tabOrDelimiter/rowOrCol/cell) line up with simlin-engine/src/json.rs (Compat, JsonDataSource, ArrayedEquation, ElementEquation) under their #[serde(rename_all = \"camelCase\")]. The dataSourceKindFromJson fallback mirrors data_source_from_json (unknown → data), and arrayedEquationFromJson's legacy inference (hasExceptDefault ?? json.equation !== undefined) mirrors arrayed.has_except_default.unwrap_or(arrayed.equation.is_some()). Empty-string activeInitial getting dropped on TS write is consistent with the engine's .filter(|s| !s.is_empty()) on read.
  • updateView recording: the new recordHistory option threads through to updateProject, which is the only thing that pushes onto projectHistory. The doc on updateView correctly notes that the snapshot serialized AFTER applyPatchOrReportError captures both the content patch and the view update in one history entry — verified by tracing the 6 modified call sites; none of them go through applyPatchAndRefresh (which would double-record).
  • Editor.tsx module-upsert path: the moduleToJson(existingModule) spread now preserves compat (canBeModuleInput / isPublic / dataSource) through model-ref/units/refs edits and through convertVariableToModule. The override order (name/modelName after the spread) is correct.
  • scripts/deploy-staging-manifests.mjs: regex /^pnpm@([^+\s]+)/ correctly handles both the pnpm@10.6.0 and pnpm@10.6.0+sha512.<hash> forms, server-declared engines.pnpm is properly overridden by the pin, and a non-pnpm packageManager leaves engines untouched. Tests cover all five cases.
  • scripts/deploy-canary.mjs: pure core (hostFromUrl/addAuthorizedDomain/removeAuthorizedDomain/parseArgs/versionTrafficShare) is well-tested; the read-modify-write PATCH against authorizedDomains correctly sends the full merged list; cleanup's traffic-share guard refuses to stop a promoted (production) version. The jest alias asymmetry in src/core/jest.config.js (internal/wasmlib/internal/wasm.node.js vs internal/backend-factorylib/backend-factory.node.js) matches the actual @simlin/engine exports map.

Overall correctness: correct. Each of the four work streams has end-to-end test coverage (the e2e test for the datamodel fix even drives the real WASM engine to catch wrong JSON keys), and the patch does not regress existing behavior in places I inspected.

@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b65dad47a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/deploy-canary.mjs Outdated

/** Stop a version's instances (cleanup). Delete is mentioned as an option. */
function stopVersion(project, id) {
run('gcloud', ['app', 'versions', 'stop', id, '--service=default', `--project=${project}`]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Delete idle canaries instead of stopping automatic versions

When cleaning up an unpromoted canary for this app, this command will fail because the production config is expected to use automatic_scaling (app.yaml:6-7), while the Google Cloud SDK docs for gcloud app versions stop state that it only works when the service scaling is manual. That means pnpm deploy:canary --cleanup <version> de-authorizes Firebase but then aborts before reclaiming the App Engine version/slot; after the existing zero-traffic guard, use gcloud app versions delete or another cleanup path that works for automatic-scaling versions.

Useful? React with 👍 / 👎.

Comment thread scripts/deploy-canary.mjs Outdated
Comment on lines +169 to +170
case '--project':
args.project = takeVal();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject --project when its value is missing

If an operator passes --project without a value (for example --project $PROJECT when the shell variable is empty, or --project --cleanup v), takeVal() returns undefined and resolveProject() silently falls back to SIMLIN_CANARY_PROJECT or the current gcloud default. Because this script deploys and mutates Firebase auth config, a missing override should fail loudly like the --cleanup value check rather than potentially operating on the wrong project.

Useful? React with 👍 / 👎.

Two fixes from the codex review of the canary script:

- Cleanup now deletes the idle canary version instead of stopping it.
  `gcloud app versions stop` only works for manual/basic scaling, but prod
  uses automatic_scaling (app.yaml), so the stop would error out and leave the
  version (and its slot toward the GAE version cap) behind after the Firebase
  de-authorization had already run. `delete` works for any scaling and frees
  the slot; the existing zero-traffic guard already ensures we never delete a
  serving (promoted) version.

- parseArgs now rejects --project with a missing or blank value. Because the
  script deploys and mutates production Firebase auth config, `--project $EMPTY`
  or `--project --cleanup v` must fail loudly rather than silently fall back to
  the gcloud default and operate on the wrong project. Omitting --project
  entirely still uses the env var / gcloud default as before.
@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Findings

[P2] handleDuplicateModelForModule still drops module compat fields

File: src/diagram/Editor.tsx:1903-1919

The PR upgrades handleCreateModelForModule (line 1838-1841) to use moduleToJson(existingModule) so the module reference preserves canBeModuleInput / isPublic / dataSource, but the very next handler handleDuplicateModelForModule still hand-constructs dupModulePayload with only name/modelName/references/units/documentation. When a user picks "Duplicate model" for an existing module variable whose compat carries canBeModuleInput, isPublic, or a Vensim dataSource, the upsert is a full-replace by UID and silently drops those fields - the same data-loss class the PR set out to fix. The fix is the same pattern used in the sibling handler: spread moduleToJson(existingModule) and override name and modelName (with an empty payload in the no-existing-module branch).


Overall correctness verdict: correct

The datamodel round-trip additions (activeInitial / dataSource / EXCEPT default / per-element graphical functions / per-element activeInitial / module compat) match the engine json.rs field names and Compat shape, the inert ApplyToAll-vs-Arrayed divergence is documented and semantically equivalent, the updateView/recordHistory rewiring records exactly one entry per discrete edit (the post-content-patch snapshot captures both halves, no double-recording) while the viewport-only stream still goes through queueViewUpdate and records nothing, the deploy-staging manifest correctly derives the bare pnpm version from a +sha512-suffixed packageManager spec and merges it onto inherited engines, and the canary script read-modify-write against the full current authorizedDomains list plus the traffic-share guard on cleanup look safe. The handleDuplicateModelForModule miss above is the only finding and is a non-blocking quality issue.

handleDuplicateModelForModule hand-built the upsertModule payload for the
re-pointed module with only name/modelName/references/units/documentation,
silently dropping compat (canBeModuleInput/isPublic/dataSource) -- the same
full-replace data-loss class the rest of this change fixes. Its sibling
handleCreateModelForModule had already been corrected, so the two had drifted.

Extract the shared, pure buildModuleReferencePayload helper into module-wiring.ts
(based on moduleToJson, overriding only name/modelName) and route BOTH handlers
through it so they cannot diverge again, and unit-test the helper (compat
preserved, bare fallback for no/non-module variable). Found in PR review.
@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2c11ac13ed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/deploy-canary.mjs Outdated
console.log('\n==> Building and deploying the staged server with --no-promote (no traffic switch)\n');
// Reuse the proven staged build/deploy orchestration; do NOT pass --version,
// so gcloud auto-generates the version id we then discover below.
run('bash', [path.join(REPO_ROOT, 'scripts/deploy-web-staged.sh'), '--no-promote']);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pass the selected project into the deploy step

When an operator uses --project or SIMLIN_CANARY_PROJECT to target a project other than the active gcloud default, this call still invokes deploy-web-staged.sh with only --no-promote; I checked that script and its final gcloud app deploy only receives its forwarded argv, so the deploy goes to the ambient gcloud project while the subsequent version lookup and Firebase authorized-domain mutation use the selected project. That can authorize/promote the wrong version and deploy HEAD to an unintended production project; pass --project=${project} through this deploy call (and thread project into this helper).

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Reviewed: datamodel.ts compat round-trip, the recordHistory plumbing through ProjectController.updateView -> updateProject -> advanceProjectHistory, the shared buildModuleReferencePayload, the canary script (pure core + gcloud/Firebase shell), and the engines.pnpm pin derivation, cross-checked against the Rust json.rs serializer and the real-WASM end-to-end test.

The JSON key names in datamodel.ts line up with src/simlin-engine/src/json.rs field-for-field (compat / dataSource / arrayedEquation / elementEquation), empty-string normalization for activeInitial is symmetric on both sides, recordHistory: true correctly produces exactly one undo entry per discrete edit while the per-frame viewport stream still records nothing, the shared module-payload helper carries every field (including uid and compat) so create-model and duplicate-model can't drift again, and the canary script's read-modify-write PATCH always sends the full authorizedDomains list and the cleanup path resolves the host before delete + refuses to delete a serving version.

No actionable bugs found.

Overall correctness: correct.

When an operator targets a non-default project via --project or
SIMLIN_CANARY_PROJECT, deployStagedNoPromote invoked deploy-web-staged.sh with
only --no-promote, so its final `gcloud app deploy` used the ambient gcloud
project while the version lookup, traffic check, version delete, and Firebase
authorized-domain mutation all used the selected project. That split-brain
could deploy HEAD to an unintended project and authorize/operate on a version
in a different one. Pass --project=<resolved> through the deploy (deploy-web-
staged.sh forwards "$@" to gcloud app deploy); resolveProject always yields a
concrete id, so this is correct and harmlessly explicit in the default case
too. Found in PR review.
@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review verdict: correct

I reviewed each commit end-to-end and found no blocking issues.

core: round-trip variable compat — JSON key names verified against src/simlin-engine/src/json.rs (the #[serde(rename_all = "camelCase")] cluster). The legacy-hasExceptDefault inference (json.hasExceptDefault ?? json.equation !== undefined) mirrors the Rust unwrap_or(arrayed.equation.is_some()). The truthy guards on if (stock.activeInitial) / if (el.activeInitial) drop empty strings, but the engine itself filters those out with .filter(|s| !s.is_empty()), so the round-trip is stable. The known inert divergence (a payload with an EXCEPT default + zero elements routes to applyToAll instead of Arrayed) is called out in-comment and is semantically equivalent. The WASM-driven datamodel-roundtrip-e2e.test.ts makes a wrong key fail loudly rather than silently agreeing with itself in a pure TS test — the right belt-and-suspenders.

diagram: record undo historyupdateView({ recordHistory: true }) records exactly once for each of the six discrete-edit handlers; for handlers that apply a content patch first via applyPatchOrReportError (which doesn't record), the trailing updateView snapshots the engine state after both the content patch and the view update, so there's no double-recording. The queueViewUpdate viewport stream stays non-recording, preserving the small-buffer protection against momentum flicks. The CLAUDE.md invariant is updated to match.

deploy: pin engines.pnpm — the pin is derived from the same packageManager spec via the same regex, the +sha512 suffix is correctly stripped for the engines pin while packageManager is preserved verbatim for corepack, and the merge order makes the derived pin win over any inherited engines.pnpm range.

deploy: canary deploy script — pure core (host parsing, list merge, arg parsing, traffic-share lookup) is unit-tested with good edge-case coverage (e.g. parseArgs(['--cleanup', '--project', 'p']) correctly throws "requires a version"). The Identity Toolkit PATCH is a read-modify-write against a fresh GET, so the ?updateMask=authorizedDomains semantics can't wipe the existing list. The cleanup-mode delete is guarded by versionTrafficShare so a post-promote cleanup can't take the site down. Preflight reads Firebase config before the long deploy so a missing firebaseauth.admin grant fails in seconds. Non-numeric / non-finite allocations are treated as 0 (not truthy), so the serving check can't be fooled by a string.

Small nit, non-blocking: the PR description says cleanup "stops the version", but the script (and docs/dev/deploy.md) deletes — the docs are right (automatic_scaling rejects stop); only the PR description text is stale.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3c9ac02a75

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

import * as fs from 'fs';
import * as path from 'path';

import { Project, configureWasm, ready, resetWasm } from '@simlin/engine';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Avoid requiring built engine artifacts in core tests

When the core package test is run from a clean checkout or after pnpm clean before pnpm build, this new runtime import is resolved by src/core/jest.config.js to src/engine/lib/index.js, which does not exist until the engine package has been built. I reproduced pnpm --dir src/core exec jest --runInBand failing with Could not locate module @simlin/engine; either map the e2e test to engine sources or skip/build-gate it so the package's documented test script remains runnable independently.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This was addressed in 8dbb1b0 (before this review ran). Line 22 is now a type-only import type { JsonProjectPatch } (erased at runtime); the @simlin/engine value import is dynamic inside loadWasm, and the suite is gated on the built libsimlin.wasm via describeIfEngine. I reproduced your exact command with the engine fully unbuilt (both src/engine/lib and the wasm removed):

$ pnpm --dir src/core exec jest --runInBand
Test Suites: 1 skipped, 2 passed, 2 of 3 total
Tests:       3 skipped, 68 passed, 71 total

No "Could not locate module @simlin/engine" -- the e2e skips cleanly and the package's standalone test script passes.

The new datamodel-roundtrip-e2e test drives the real WASM engine, so it
statically imported @simlin/engine and read libsimlin.wasm. On a clean checkout
(or after pnpm clean) before pnpm build, `pnpm --filter @simlin/core test`
then failed at module resolution (Could not locate module @simlin/engine),
breaking core's documented standalone test script.

Make the @simlin/engine value import dynamic (inside loadWasm) and gate the
suite on the presence of the built libsimlin.wasm: skip with a clear note when
the engine is not built, run fully otherwise. CI and the pre-commit hook build
before testing, so the integration coverage is unchanged there; the pure
datamodel.test.ts already covers every field without the engine. Found in PR
review.
@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Reviewed the three fixes plus the canary script. Traced the data round-trip via key names against src/simlin-engine/src/json.rs (camelCase, kind enum, hasExceptDefault), the undo history threading (applyPatchOrReportError + updateView({recordHistory:true}) records exactly one entry covering both content and view; applyPatchAndRefresh callers never chain updateView, so no double-recording), the engines.pnpm regex stripping +sha512.<hash>, and the canary's read-modify-write + traffic-share guard. The e2e test that drives the real WASM serializer through an upsert pins the bug class. No blocking findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8dbb1b05f6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/core/datamodel.ts Outdated
Comment on lines +299 to +300
// hasExceptDefault. This is semantically equivalent (a default with no excepted
// subscripts applies to all elements either way) and real models never emit it.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep zero-element arrayed equations arrayed

This fallback is not equivalent when the serialized arrayed equation has elements: [], a default equation, and hasExceptDefault: false (for example an EXCEPT that excludes every subscript). The engine represents that as Arrayed with apply_default_for_missing=false, so omitted elements evaluate as 0; converting it to applyToAll makes every element use the default equation and the next editor upsert persists that changed behavior. Please treat a present elements array as arrayed even when it is empty, and apply the same fix in the aux/flow path below.

Useful? React with 👍 / 👎.

The engine distinguishes ApplyToAll from Arrayed by the PRESENCE of the JSON
`elements` field, not by it being non-empty: json.rs omits `elements` for
ApplyToAll and always emits it for Arrayed (even as []). datamodel.ts routed an
`elements: []` payload to applyToAll, which is not equivalent when the arrayed
equation carries a default and hasExceptDefault:false (an EXCEPT excluding every
subscript): the engine leaves every missing element at 0 (compiler/mod.rs uses
the default only when apply_default_for_missing is true), but applyToAll makes
every element use the default -- a silent behavior change the next editor upsert
would persist.

Route on `elements` presence (`!== undefined`) in both the stock and the
aux/flow paths so a zero-element arrayed equation stays arrayed and round-trips
hasExceptDefault. The hasExceptDefault:true case stays equivalent. Found in PR
review.
@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b0316e574a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/core/datamodel.ts Outdated
Comment on lines +738 to +739
canBeModuleInput: json.compat?.canBeModuleInput ?? false,
isPublic: json.compat?.isPublic ?? false,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve legacy module flags on import

When the editor opens a project that still uses the legacy top-level module fields canBeModuleInput / isPublic, these newly added fields are initialized to false because only json.compat is read. The Rust JSON layer still accepts those legacy fields for modules, and stock/flow already OR-merge their legacy top-level flags, so a subsequent module edit/upsert will silently drop module input/public visibility for those projects. Please OR these with the legacy top-level values as well.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Reviewed the PR end-to-end (datamodel round-trip, undo recording, manifest pin, canary script). Most of the change is well-tested and the defensive coverage is strong; I have one small follow-up.

[P3] addAuthorizedDomain no-op detection can incorrectly skip the PATCH when the existing list has duplicates

scripts/deploy-canary.mjs:501-506

const after = addAuthorizedDomain(before, host);
printDomains('before', before);
if (after.length === before.length) {
  console.log(`    ${host} is already authorized -- no change.`);
} else {
  await patchAuthorizedDomains(project, token, after);

addAuthorizedDomain dedupes its input (the 'dedups pre-existing duplicates while preserving order' unit test pins this: (['a','a','b'], 'c') -> ['a','b','c']). So if the live authorizedDomains ever contains a duplicate entry and the canary host is genuinely new, after.length === before.length is true and the script logs "<canary-host> is already authorized -- no change" and skips the PATCH — the canary host is not actually added, and the operator's Google sign-in will silently keep failing. Same script with removeAuthorizedDomain in cleanup is fine (no dedup, length only changes on a real removal). Firebase normally normalizes the list so duplicates are unlikely, hence the low priority; the precise check is if (before.includes(host)).

Overall correctness

Correct (modulo the edge case above).

The engine's JSON reader (json.rs) OR-merges the legacy top-level
canBeModuleInput / isPublic fields with compat for stock, flow, aux, AND module
(read from old JSON, never written). datamodel.ts only OR-merged the legacy
nonNegative flag (stock/flow) and read canBeModuleInput/isPublic from compat
alone, so a project serialized in the old Go `sd` JSON schema -- visibility
flags at the top level rather than under compat -- would import them as false
and silently drop module-input / public visibility on the next edit+upsert.

Mirror the engine reader: OR-merge the legacy top-level flags in all four
readers, and declare the deprecated fields on the four Json* types. This is
defense-in-depth for the current editor (the engine serializer normalizes to
compat before datamodel.ts sees the JSON), but it removes a real divergence
between the two JSON readers. Found in PR review.
@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 85a502934d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review Summary

I reviewed all 10 commits in this PR. The changes are well-scoped, well-tested, and well-documented. Each of the three main concerns is appropriately addressed:

  1. Variable round-trip data loss fix (datamodel.ts) - the new fromJson/toJson handling for activeInitial, dataSource, hasExceptDefault, per-element compat, and module compat is consistent with the engine's Rust serializer in json.rs, and the new datamodel-roundtrip-e2e.test.ts exercises the real WASM engine to catch JSON-key drift. Commits 6, 9, and 10 are good follow-up fixes from review: the buildModuleReferencePayload helper consolidates the two hand-built duplicate handlers; the elements !== undefined routing in stockEquationFromJson/auxEquationFromJson correctly distinguishes Arrayed (with empty elements + default) from ApplyToAll; and the OR-merge of legacy top-level visibility flags mirrors the engine's reader.

  2. Undo history regression fix (Editor.tsx, project-controller.ts) - the new recordHistory opt on updateView correctly produces one history entry per discrete edit. The handlers that combine applyPatchOrReportError (non-recording) with updateView({...}, { recordHistory: true }) correctly capture the post-content-patch + post-view-update engine state in a single recorded snapshot, so there is no double-recording. The viewport-only queueViewUpdate path remains non-recording. Tests cover both the recording behavior and the undo-restoration path.

  3. Canary deploy script + manifest pin (scripts/) - the staged-server engines.pnpm pin is correctly derived from packageManager (stripping the +sha512 suffix to take the buildpack's exact-semver fast path). The canary script is split into a pure tested core and a thin shell. The Firebase authorizedDomains updates use a read-modify-write pattern with updateMask=authorizedDomains, which the comments correctly note replaces the whole repeated field (so the full current list must always be sent). The cleanup path correctly guards delete on the version's current traffic share, switching to delete instead of stop (right call for automatic_scaling). Commits 5 and 7 are good follow-ups: --project now rejects missing/blank values to avoid silent fallback to the wrong project, and the resolved project is threaded into deploy-web-staged.sh so the deploy and the post-deploy lookups cannot split-brain.

No findings to flag

I do not see any concrete, blocking bugs introduced by this PR. A handful of minor edge cases exist (e.g. the canary cleanup will exit early if gcloud app browse --version=<id> fails because the version was already deleted manually; a multi-hour deploy could outlive the access token between the preflight and the post-deploy PATCH; the truthy check on stock.activeInitial would drop a Some("") activeInitial), but these are all acceptable behavior for a manual, single-operator tool or for fields whose empty-string state is not semantically meaningful.

Verdict

Correct. The patch is free of blocking issues; existing tests pass and the new tests appropriately cover the changed behavior.

@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 85a502934d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/core/datamodel.ts Outdated
isPublic: json.compat?.isPublic ?? false,
canBeModuleInput: json.compat?.canBeModuleInput || json.canBeModuleInput || false,
isPublic: json.compat?.isPublic || json.isPublic || false,
activeInitial: json.compat?.activeInitial,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Read arrayedEquation compat activeInitial

When importing legacy/native JSON where an arrayed flow or auxiliary carries ACTIVE INITIAL under arrayedEquation.compat (the Rust reader still supports this fallback in src/simlin-engine/src/json.rs), this line leaves activeInitial undefined unless top-level compat also has it. A later full upsert via flowToJson/auxToJson will then omit the ACTIVE INITIAL and drop that initialization metadata; use the same top-level-then-arrayed fallback here and on the aux path.

Useful? React with 👍 / 👎.

The engine's JSON reader (json.rs) reads a flow/aux ACTIVE INITIAL from the
top-level compat first and falls back to arrayedEquation.compat.active_initial,
a legacy/native JSON shape. datamodel.ts read only the top-level compat, so an
arrayed flow/aux that stored ACTIVE INITIAL on its arrayed equation imported it
as undefined and a later full upsert via flowToJson/auxToJson dropped the
initialization. Mirror the engine: top-level compat wins, else fall back to the
arrayed equation's compat. Stocks have no such fallback in the engine reader
(and it never writes one), so they are intentionally left unchanged. Found in
PR review.
@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@claude

claude Bot commented Jun 28, 2026

Copy link
Copy Markdown

Review

I reviewed the four areas: the datamodel round-trip fix, the undo history fix, the engines.pnpm pin, and the canary deploy script. Spot-checked the new TS round-trip code against the Rust engine's From impls in src/simlin-engine/src/json.rs line-by-line:

  • stockFromJson correctly omits the arrayedEquation.compat.activeInitial fallback (matches From<Stock> at json.rs:730-753)
  • flowFromJson/auxFromJson include the fallback (matches From<Flow>/From<Auxiliary> at json.rs:812-838/890-916)
  • moduleFromJson only carries canBeModuleInput/isPublic/dataSource (matches From<Module> at json.rs:977-1009)
  • The OR-merge of legacy top-level booleans (|| instead of ??) is intentional and matches the engine's read semantics; the comment correctly notes that old code never wrote compat booleans and new code never writes top-level booleans, so they cannot meaningfully conflict
  • arrayedEquationFromJson's hasExceptDefault ?? json.equation !== undefined mirrors json.rs:759-761 exactly
  • Routing on presence of elements (not non-empty) is correct given the engine's distinction between ApplyToAll (omits elements) and Arrayed (always emits, even []); the new EXCEPT-excludes-all test pins this
  • The e2e roundtrip test driving the real WASM engine is the right validation for JSON key names; the unit tests would otherwise miss a typo where both halves agree

For the undo fix: the updateView(view, opts) default of recordHistory: false preserves legacy non-recording behavior for bare calls, and only the five discrete edit handlers (delete, label-side, flow-attach, variable-create, group-move) opt in via { recordHistory: true }. The viewport stream still goes through queueViewUpdate (unchanged, non-recording), so a momentum flick still cannot evict real edits. The new tests in project-controller.test.ts verify both the cursor advancing and the actual snapshot reopen.

For buildStagingServerManifest: the /^pnpm@([^+\s]+)/ regex correctly strips the +sha512.<hash> suffix while preserving the verbatim packageManager string (which corepack needs). The engines merge preserves any server-declared engines.

For the canary script: the read-modify-write pattern is correct and the preflight GET fails fast before the long deploy; versionTrafficShare guards delete against a promoted canary; passing --project=${project} through to deploy-web-staged.sh correctly avoids the split-brain case the comment describes. The parseArgs rejection of empty --project values prevents silent fallback to the gcloud default.

Overall correctness: correct

No blocking bugs found. The PR is comprehensively tested (pure round-trip tests + real-engine e2e + manifest tests + controller history tests + canary script pure-core tests) and the comments document non-obvious decisions (the elements presence-vs-non-empty distinction, the OR-merge rationale, the cleanup version-traffic guard) well.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Hooray!

Reviewed commit: a93d385856

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@bpowers

bpowers commented Jun 28, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: a93d385856

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@bpowers bpowers merged commit 2546758 into main Jun 28, 2026
15 checks passed
@bpowers bpowers deleted the fix/pre-deploy-blockers branch June 28, 2026 14:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant