Skip to content

fix(platform): re-inline sub-rules on redeploy and rollback (silent corruption)#161

Closed
Pama-Lee wants to merge 6 commits into
mainfrom
fix/platform-publish-inline
Closed

fix(platform): re-inline sub-rules on redeploy and rollback (silent corruption)#161
Pama-Lee wants to merge 6 commits into
mainfrom
fix/platform-publish-inline

Conversation

@Pama-Lee

@Pama-Lee Pama-Lee commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Fixes the highest-severity finding from the 2026-07-09 publish-path audit: redeploy and rollback-to-version silently drop a ruleset's sub-rule logic. Stacked on #160 (base = fix/platform-cross-org-isolation); GitHub retargets to main as the stack merges.

The bug (silent data corruption)

A direct publish stores the authored (un-inlined) studio draft in its deployment row (ruleset_draft.rs — the row is built from draft.draft before sub-rules are inlined), and rows predating inlining have no sub-rule graph either. Redeploy (redeploy) and rollback-to-version (build_rollback_context) then dispatched that stored snapshot verbatim:

  • its SubRule steps reference a graph that isn't in the payload, and
  • studio→engine conversion drops a dangling sub-rule reference silently (ConvertError::SubRuleNotFound is defined but never returned — the conversion builds the step with no existence check).

So the "same version" you redeploy or roll back to runs with its sub-rule decision logic missing, changing outcomes on the engines with no error surfaced. The governed release path is unaffected (it inlines and freezes target_ruleset_snapshot at request-creation time).

The fix

Re-inline the snapshot at both dispatch points before rolling it out. Inlining is idempotent — an already-inlined graph is skipped via the sub_rules.contains_key guard in inline_sub_rules_with_manifest — so this:

  • heals old rows and direct-publish rows (populates the missing graph from the current sub-rule assets), and
  • no-ops on already-inlined snapshots (governed releases).

Details:

Note on semantics

For an un-inlined stored snapshot there is no frozen sub-rule copy, so re-inlining pulls the sub-rule's current content — the best (and only) available, and strictly better than dispatching a dangling reference. Already-inlined (governed) snapshots keep their frozen content untouched.

Verification

cargo test -p ordo-platform (40 tests) + clippy --all-targets clean. The idempotency the fix relies on is guaranteed by the contains_key guard (this crate has no DB-backed test harness to exercise inlining end-to-end; worth a cluster smoke-test: publish a ruleset with a sub-rule, redeploy it, confirm the engine still runs the sub-rule branch).

Pama-Lee added 6 commits July 8, 2026 23:35
The ReleaseExecutionStatus and ReleaseRequestStatus enums both include
rollback_failed, but the CHECK constraints from 0008 never did. A failed
rollback therefore could not persist its terminal status: the UPDATE hit
the constraint, the error was swallowed, the execution stayed
rollback_in_progress, and the worker re-claimed and re-ran the failing
rollback every 2s poll, forever.

Rebuild both CHECKs with the full status set the code writes.
The 0020 unique index guarantees at most one active execution per
release request, but it cannot see across requests: every direct
publish/redeploy mints a fresh internal request, so two concurrent
publishes of the same ruleset (or a publish racing a governed execute)
would still both roll out.

Add a target-level guard - any active execution for the same
project + ruleset + environment now returns 409 from both
execute_release_request and the direct-publish enqueue path.

Also map the 0020 unique-index violation to 409 instead of 500 for the
loser of two concurrent executes of the same request.
The old safety net (fail_stuck_active_executions) was dead code: both
bootstrap callers passed false, and enabling it would have been wrong
anyway - it had no grace period and would kill in-flight rollouts on
every restart, and its rollback_failed write predates the 0022 CHECK
fix. Remove it.

The poll loop already self-heals claimable executions (the session
advisory lock releases when a dead worker's connection drops), but a
terminal execution is not claimable, so a worker crash between settling
the execution and settling the surrounding state stranded rows forever:

- dispatched deployments whose execution already finished (studio polls
  them indefinitely)
- executing requests whose latest execution already finished
- executing requests that never got an execution row

Add a 60s reconciliation task in the worker that repairs all three,
with grace periods so it never races the worker's own settle writes.
Completed-via-rollback executions are told apart from completed
rollouts by their rollback_in_progress -> completed history row, so
requests settle to rolled_back exactly as the worker would have
written. Repairs are recorded in the request history and are
idempotent, healing rows stranded before this task existed.
A rolling_out / rollback_in_progress execution is worker-claimable, and
build_rolling_context / build_rollback_context hard-errored when a row
they depend on was gone (a bound server pruned as stale >30min or
deleted by an admin, the request, environment, draft, or rollback
snapshot). The spawned worker task only logged that error and dropped
its advisory lock without writing any status, so the execution stayed
claimable and was re-claimed and re-failed every 2s poll forever. The
reconciler could not help — it only repairs executions that already
reached a terminal status.

Distinguish a permanent missing dependency (SetupDependencyMissing, from
a None lookup) from a transient store error (propagated, retried next
poll). On a permanent failure, settle the execution terminally (Failed,
or RollbackFailed for a rollback) along with its request and any
direct-publish deployment row, instead of looping.
Three handlers took an id from the path and fetched by that id alone,
after only a coarse auth check — and server ids are derivable
(sha256 of the engine URL), so the ids are guessable across orgs:

- get_server / get_server_metrics / get_server_health ignored the
  caller's claims entirely. Any authenticated user could read another
  org's engine (url, version, capabilities) and, via metrics/health,
  trigger a control-plane NATS RPC to it. Now an org-owned server
  requires the caller to be a member of that org; global (unowned)
  servers stay readable, matching delete_server's gating.

- get_release_execution_for_request / list_release_execution_events
  checked the permission on the path org/project but then fetched by
  the unscoped release_id / execution_id. An admin in their own org
  could read another org's execution state and event stream with a
  guessed id. Both now resolve the release through the org+project
  scoped getter (404 on mismatch), and the events handler additionally
  verifies the execution belongs to that release.

Also scope the engine-proxy fallback: when a project's environment has
no bound server (the auto-created production env has none, so this is
common), the fallback pool is now restricted to the caller's org plus
deliberately-global servers, never another org's engine — previously it
picked the most-recently-registered server anywhere, routing org A's
eval traffic and ruleset payloads to org B's engine.
A direct publish stores the *authored* (un-inlined) studio draft in its
deployment row, and rows predating inlining have no sub-rule graph
either. Redeploy and rollback-to-version then dispatched that stored
snapshot verbatim: its SubRule steps referenced a graph that wasn't
there, and studio->engine conversion drops a dangling reference
silently (ConvertError::SubRuleNotFound is defined but never returned).
The 'same version' redeployed or rolled back to therefore ran with its
sub-rule logic missing, changing decisions with no error surfaced.

Re-inline the snapshot at both dispatch points before rolling it out.
Inlining is idempotent (an already-inlined graph is skipped via the
sub_rules.contains_key guard), so it heals old/direct-publish rows and
no-ops on already-inlined ones (governed releases). Redeploy inlines
before creating the deployment row so a now-missing sub-rule fails fast
without orphaning a Dispatched deployment; rollback maps an
unassemblable snapshot to SetupDependencyMissing so it settles as
RollbackFailed instead of looping.
@vercel

vercel Bot commented Jul 9, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
ordo_docs Ready Ready Preview, Comment Jul 9, 2026 10:03am

@Pama-Lee
Pama-Lee changed the base branch from fix/platform-cross-org-isolation to main July 9, 2026 11:38
@Pama-Lee

Pama-Lee commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by a re-parented PR onto current main (stacked-squash history conflict). Same change.

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