Skip to content

Per-slot action plan, slot outcome history & builder testing knobs#134

Merged
pk910 merged 31 commits into
mainfrom
pk910/testing-capabilities
Jul 14, 2026
Merged

Per-slot action plan, slot outcome history & builder testing knobs#134
pk910 merged 31 commits into
mainfrom
pk910/testing-capabilities

Conversation

@pk910

@pk910 pk910 commented Jul 12, 2026

Copy link
Copy Markdown
Member

Per-slot action plan, slot outcome history & builder testing knobs

Adds a per-slot action plan to buildoor so operators can script different
behaviour for individual slots during Gloas/ePBS fork testing, records the
outcome of every active slot (down to the exact SSZ objects), and surfaces
both in a new Action Plan WebUI tab. On top of that it adds targeted
testing knobs — parent-payload reorg builds and arbitrary jq transforms of
the payload/bid/envelope — plus a batch of WebUI robustness fixes.

Buildoor is a Gloas/ePBS development & testing tool, not a mainnet builder;
several features here (underbidding, parent reorg, arbitrary object rewriting)
are deliberately adversarial and are rejected by mainnet forkchoice.

Branch: pk910/testing-capabilities · base: main (2c374b8)


What you can do now

  • Script any slot. Force or suppress bidding (p2p), Builder-API bid serving,
    and reveal on individual slots — even against the global enable flags — and
    override amounts, windows, response delays, reveal timing. Plan far ahead; a
    slot's plan freezes ~1 slot before execution and becomes immutable.
  • Inspect what happened. Every slot where ePBS or the Builder API was active
    gets an attempt-level result record (build lifecycle, every bid with competitor
    context, block submissions, reveal attempts, inclusion + canonical verdict) with
    the frozen plan snapshotted in, plus the exact SSZ artifacts (built payload,
    every signed bid, the envelope — even when a reveal was withheld) served as SSZ
    or JSON via Accept negotiation.
  • See it live. An epoch × slot timetable with plan chips + a two-dot outcome
    per cell (bid status / canonical payload verdict), click-to-edit modals, bulk /
    range editing, artifact downloads, all updating live over the existing SSE
    connection.
  • Reorg the parent payload. Per-slot flag to build on the grandparent (n-2)
    execution payload — a deliberate parent-payload reorg attempt.
  • Rewrite the objects. Per-slot jq expressions that modify the payload, bid
    message, or envelope message (bid/envelope re-signed) — for custom builder
    behaviour the tool isn't aware of — with a live in-modal expression tester.

Architecture

Four new packages and an extended data model, wired as a single per-slot
authority feeding downstream consumers by frozen snapshots.

pkg/action_plan — the per-slot scheduling authority

The PlanService is the single owner of per-slot scheduling and settings
(the old SlotManager is gone; the schedule modes all/every_nth/next_n and
next_n accounting live here). Consumers receive it as a mandatory constructor
dependency and poll it — no module interprets raw plans or schedule config
itself, no nil-guards, no fallback paths.

  • Sparse plans. Categories bid / builder_api / reveal are three-state:
    absent = inherit the global baseline, disabled = suppress, custom =
    force-active (even when the module is globally disabled) with optional
    overrides. Two further modeless categories: build (reorg_parent_payload)
    and transforms (jq expressions).
  • Freeze semantics. Freeze(slot) resolves an immutable FrozenPlan — raw
    plan + effective settings merged from the live global config + the target-slot
    fork + the complete build decision — the first time any decision point touches
    the slot; every later caller gets the identical snapshot, so later config
    changes never rewrite a partially executed slot. Edits to past or frozen slots
    fail with ErrSlotLocked (HTTP 409).
  • Atomic bulk mutations. ApplyUpdates is all-or-nothing, supports slot
    lists + inclusive ranges, three-state category patches, and fine-grained set
    paths ("bid.bid_min_amount": 5000) so consumers never send a full category
    object for a partial edit. Committed changes fire PlanChangeEvent.
  • Persisted via the kv_store slot_plans namespace; past plans prune to
    retention, future plans never.

pkg/slot_results — generic per-slot outcome history

  • Attempt-aware SlotResult per active slot: build lifecycle (incl.
    waiting_attributes/no_attributes baselines from a slot clock, so "planned
    but nothing happened" is visible), bid attempts (both transports), block
    submissions, reveal attempts, inclusion + a reorg-aware canonical payload
    verdict — plus the frozen applied_plan. Copy-on-write records, attempt caps
    with a dropped counter, coalesced SSE updates. Consumes the producer services'
    blocking subscriptions so history is loss-free.
  • ArtifactStore — raw SSZ artifacts per slot (payload / every signed bid /
    envelope) with a write-through 64-slot memory buffer plus an async batching
    writer into a dedicated slot_artifacts SQLite table (blobs are too large for
    the in-RAM memstore pattern; pruning needs a SQL range delete). Hot paths never
    wait on SQLite.
  • Serves the existing Bids Won view as a filtered included-slot view and
    migrates the legacy won_blocks kv namespace once on startup (merge-safe,
    idempotent, crash-safe).

pkg/jqtransform — sandboxed jq

Wraps itchyny/gojq (pure Go, no I/O): environment access disabled,
single-output enforced, context-timeout. ApplyTyped round-trips a
fork-agnostic object through MarshalJSON → gojq → UnmarshalJSON into a fresh
object with Version preset.

pkg/dbslot_artifacts table

New goose migration (00003_slot_artifacts.sql) + repository:
(slot, kind, idx) primary key, batched insert, range-delete prune.


Feature detail

Reorg-aware canonical payload verdicts

The inclusion tracker no longer does a one-shot follow-up check. Each won slot is
tracked for a 16-slot window; on every head event it walks the head's ancestry
(cached by block root) down to the won slot to derive a verdict — canonical
(the next canonical block builds on our payload), missed (builds on an older
execution block: payload withheld / late / voted empty), or orphaned (the won
block itself was reorged out). Reorgs flip verdicts; every change fires a
PayloadStatusEvent recorded on inclusion.payload_status and pushed over SSE.

Parent-payload reorg build (build.reorg_parent_payload)

Builds the slot's payload on the grandparent (n-2) execution payload: the FCU
head hash, the parent block number, and the withdrawals come from the parent
slot's cached payload attributes (whose parent is n-2), while every other
property — including the beacon parent root — stays from the current slot. The
effective attributes are built once and stored on the Payload, so the built
payload and the bid derived from it agree on the reorged parent. Falls back
to a normal build (logged) when the parent slot's attributes are unavailable.

jq transforms (transforms.payload / .bid / .envelope)

  • payload rewrites the built execution payload before it feeds both the bid
    commitment and the reveal (Payload.BlockHash re-synced from the result).
  • bid / envelope rewrite the message just before signing, then
    re-sign with the target-slot fork — so results are validly signed but
    customized, and a bid commitment can deliberately diverge from the revealed
    payload.
  • Expressions are jq-validated at plan-update time (400 on bad jq); a runtime
    transform failure fails that construction loudly rather than signing the
    untransformed object.
  • Live testing: POST /api/buildoor/action-plan/test-transform runs the
    exact production gojq against a captured artifact for the slot (bid/envelope
    reduced to .message), else the latest buffered artifact, else an illustrative
    template — powering a debounced input/output preview in the edit modal.

SSE connect-time replay

Every slot-scoped event is kept in a 5-slot server-side replay cache and
prefilled into a new client's channel at registration (registration and
broadcasting serialize on one mutex → one ordered, gapless stream). Events carry
a monotonic seq (seeded from wall-clock micros to survive restarts); the
frontend keeps a high-watermark and drops replayed duplicates on reconnect. The
UI now restores the slot graph and event log on connect instead of starting
empty.

Security fix — Builder API plan-freeze DoS guard

getHeader / getExecutionPayloadBid reject slots beyond currentSlot+1 (400)
before freezing, so a client cannot lock arbitrary future slot plans.


API surface

REST (pkg/webui/handlers/api):

  • GET /api/buildoor/action-plan?min_slot=&max_slot= — per-slot plans in range.
  • POST /api/buildoor/action-plan — atomic bulk mutation (three-state
    categories + set paths); past/frozen slots → 409 (auth + audit).
  • POST /api/buildoor/action-plan/test-transform — evaluate a jq expression
    against a sample object.
  • GET /api/buildoor/slot-results?min_slot=&max_slot= — attempt-level history.
  • GET /api/buildoor/slot-results/{slot}/payload|envelope|bids|bids/{index}
    raw SSZ artifacts with Accept-based SSZ/JSON content negotiation.
  • POST /api/config/settings — generic path-based global settings update.
  • GET /api/buildoor/bids-won — now served from the slot results tracker.

SSE (/api/events): action_plan_updated, slot_result_updated, the
connect-time replay burst + seq, plus the existing event set.

Config: --slot-result-retention-epochs (100), --slot-artifact-retention-epochs
(100), --slot-artifact-capture-enabled (true), --epbs-bid-value-override,
--builder-api-value-override.


Notable fixes bundled in

  • Dispatcher.Unsubscribe never removed subscriptions (reversed guard) —
    this leak also blocked loss-free blocking subscriptions; fixed with tests.
  • Bid & envelope signing used the current fork instead of the target slot's
    fork
    — wrong at the first Gloas slot; both fixed.
  • Legacy Builder-API subsidy uint64 overflow (subsidy * 1e9) — moved to
    uint256.
  • WebUI: Bids Won infinite refetch loop on the SSE replay burst; Action Plan grid
    not applying live slot_result_updated (slot marshaled as a JSON string);
    withheld reveals rendered as successful; replayed slots not shown in the
    timeline; transform test input/output rendered as objects (React Remove extra data modifier for the payload #31).

Data & migration notes

  • Everything is in-memory unless --state-db <path> is set. With it:
    settings, kv_store (incl. slot_plans, slot_results), and the
    slot_artifacts table persist across restarts; the legacy won_blocks
    namespace is migrated into slot_results once and deleted.
  • One new goose migration (00003_slot_artifacts.sql), applied automatically.
  • No breaking changes to existing wire shapes (Bids Won unchanged).

Testing

  • New unit tests for action_plan (freeze truth table, bulk/path updates,
    build & transform categories), slot_results (tracker, artifacts, migration),
    jqtransform (env-blocking, single-output, cancellation), the inclusion
    tracker's reorg verdicts, the bid/envelope transform round-trip + re-sign, the
    SSE replay cache, and the transform test endpoint.
  • Verified: make build, full go test ./..., -race on the touched packages,
    tsc --noEmit, and the production webpack build — all green.

pk910 added 30 commits July 12, 2026 00:18
The reversed nil-guard in Dispatcher.Unsubscribe made every call an
early-return, leaking all subscriptions. Track removal purely via slice
membership (the dispatcher field stays immutable after Subscribe), which
also makes double-unsubscribe race-free. Add dispatcher tests incl.
blocking/non-blocking delivery and concurrent subscribe/unsubscribe/fire.
…lues

Adds five mutable settings (default < CLI < UI resolution like all others):
- slot-result-retention-epochs (100): per-slot plan+result history window
- slot-artifact-retention-epochs (100): raw SSZ artifact window
- slot-artifact-capture-enabled (true): toggle raw artifact capture
- epbs-bid-value-override (0=off): absolute p2p bid base, alternative to
  the subsidy formula; allows underbidding the block value
- builder-api-value-override (0=off): absolute total served bid value

Retention values reject 0 via settings validation.
Sparse per-slot operation modes for bidding, builder-api serving and
reveal. Categories are explicit instructions: absent = inherit the global
baseline, disabled = suppress, custom = force-active with optional
overrides (custom activates the flow even when the module is globally
disabled; availability gates still win).

PlanService.Freeze(slot) resolves an immutable FrozenPlan (raw plan +
effective settings merged from the live global config + target-slot fork)
that every decision point of the slot observes; edits to frozen or past
slots fail with ErrSlotLocked (409 at the API layer). Bulk updates are
atomic all-or-nothing with three-state category patches (absent/null/
object, strict decoding) and overflow-safe range targeting.

Plans persist via the kv_store "slot_plans" namespace; past plans are
pruned to slot-result-retention-epochs on epoch transitions, future plans
never.
Dedicated table for raw per-slot SSZ artifacts (built payloads, signed
bids, signed envelopes) — deliberately not a kv_store namespace: blobs
are too large for the in-RAM memstore pattern, and pruning needs a SQL
range delete on the integer slot column. Batch inserts run in one
transaction; MAX(idx) lookup makes per-slot bid index allocation
restart-safe. Disabled-database no-op semantics match the other
repositories.
…ority

Freeze(slot) now resolves the COMPLETE build decision into the frozen
snapshot (ResolvedBuildSettings): global schedule (all/every_nth/next_n,
start slot), plan force/suppress, forced-build marking and the effective
build start time — plan > global > defaults, in one place. The service
owns the next_n accounting (OnSlotBuilt; forced builds exempt),
UpdateConfig counter resets, and schedule stats. Consumers poll frozen
snapshots instead of interpreting raw plans themselves.
The SlotManager is gone: the plan service is the scheduling authority.
On payload_attributes the service freezes the slot's plan and acts on
frozen.Build — decision, skip reason, and build start time all come from
the snapshot; successful builds report back via planSvc.OnSlotBuilt.
Plan-involved skips fire the new BuildSkippedEvent (deduped per slot) so
the slot results tracker can explain unbuilt slots. planSvc is a
mandatory constructor dependency; no fallback paths.
The scheduler freezes each slot's plan on first evaluation and the
snapshot alone decides whether and how the slot is bid on: frozen
windows/interval/amounts, absolute bid-value base (allows underbidding),
optional proposer-preferences-gate bypass, and per-slot enable in both
directions (a plan can activate bidding while ePBS is globally disabled
and vice versa — the service enabled flag is status reporting only).
The scheduler holds no config anymore; Service.UpdateConfig and the cfg
constructor param are gone.

CreateAndSubmitBid now returns the constructed signed bid even when the
gossip submission fails, and signs with the TARGET slot's fork (the
current-fork lookup was wrong at the first Gloas slot). BidSubmissionEvent
carries status, the signed bid and the highest competitor bid (new
tracker query excluding our own builder index). All value math is
overflow-clamped.
RevealService resolves reveal timing/suppression exclusively from the
slot's frozen plan: plan-disabled slots fire a terminal skipped result
(reason plan_disabled) and keep the dedupe entry so no later request can
publish; custom reveal times bypass the in-slot deadline for late-reveal
testing. Envelope construction is split from network publish so every
RevealResult attempt carries the built envelope (failed publishes stay
inspectable), and envelope signing uses the TARGET slot's fork instead
of the current fork.

The InclusionTracker no longer stores won blocks — it builds the WonBlock
summary for PayloadIncludedEvent only; durable storage moves to the slot
results tracker. WonBlock/WonBlocksNamespace/WonBlockCodec stay for the
wire shape and the one-time namespace migration.
…rding

Bid serving in both dialects (legacy getHeader, Gloas
getExecutionPayloadBid) is decided exclusively by the slot's frozen plan,
resolved at request time: suppressed slots 204, plan-activated slots
serve even when the module is globally disabled (the enabled flag keeps
gating only the non-slot-scoped endpoints). planSvc is a constructor
dependency of NewServer and both dialect handlers.

Frozen per-slot values drive the response: subsidy override, absolute
total bid value (legacy path now composes wei in uint256 — also fixes a
pre-existing subsidy*1e9 uint64 overflow), and a context-cancellable
response delay capped at one slot.

Requests beyond currentSlot+1 are rejected with 400 BEFORE freezing, so
arbitrary clients cannot lock future plans against edits.

A narrow SlotResultRecorder interface (wired later by the results
tracker) records bid outcomes — served only after a successful response
write, suppressed/failed/cancelled otherwise, with the exact signed
object for artifact capture and polling dedupe — plus block submission
outcomes on all legacy v1/v2 and Gloas submit paths.
The plan service starts right after the chain service (step 7b), with
kv_store persistence and a flush-before-close defer, and is a mandatory
constructor dependency of the payload builder, p2p bidder, reveal
service and Builder API server. Settings changes route to
planSvc.UpdateConfig for next_n counter resets; the p2p bidder no longer
takes or receives config. The inclusion tracker's state-db hookup is
gone (won-block storage moves to the slot results tracker).
…facts

New package owning the generic slot history: one SlotResult per slot
where ePBS or the Builder API was active, with attempt-level records
(build lifecycle incl. waiting_attributes/no_attributes baselines from a
catch-up-bounded slot clock, bid attempts with statuses and competitor
context, block submissions, reveal attempts, inclusion) plus the frozen
applied plan snapshotted at record creation. Records are immutable
copy-on-write values; attempts cap at 256/kind with a dropped counter;
update events coalesce per slot for the SSE bridge.

The ArtifactStore captures the raw SSZ objects (built payload, every
signed bid with restart-safe per-slot indices, the signed envelope at
construction time) through a bounded in-memory buffer (newest 64 slots,
works without a state-db) and an async batching writer into the
slot_artifacts table — hot paths never wait on SQLite. Capture toggles
via slot-artifact-capture-enabled.

The tracker consumes the services' blocking subscriptions (Subscribe*
methods gained a blocking flag), implements the builderapi
SlotResultRecorder contract, serves the Bids Won view unchanged
(slot-descending, offset/limit), migrates the legacy won_blocks
namespace once (merge-safe, idempotent, crash-safe across its two
batches), and prunes summaries and artifacts to their separate epoch
windows.
The tracker starts after all event sources exist and before any producer
service (step 12b) so its blocking subscriptions never miss an event,
persists via the slot_results namespace (running the won_blocks
migration), drains before the state-db closes, and registers as the
Builder API's result recorder. The WebUI's Bids Won endpoint reads the
tracker's included-slot view (wire shape unchanged); the plan service
and tracker are threaded into the API handler for the upcoming plan and
result endpoints.
PlanUpdate gains a "set" member of dotted paths applied after the
category patches, so partial edits never require full category objects:
"bid.bid_min_amount": 5000 updates one override (null clears it back to
inherit), "reveal.mode" switches modes, "builder_api" with null/object
keeps the whole-category semantics. Setting a field on an absent
category creates it as custom; unknown paths/fields are rejected via the
strict decoder; paths apply in lexicographic order.
New REST surface:
- GET/POST /api/buildoor/action-plan — range reads and atomic bulk plan
  mutations (path-based partial updates supported); auth + audit; frozen
  or past slots map to 409; the response carries the authoritative
  normalized plans so clients never guess patch results
- GET /api/buildoor/slot-results — attempt-level outcome history per
  slot range
- GET /api/buildoor/slot-results/{slot}/payload|envelope|bids|bids/{i} —
  raw artifacts with beacon-API content negotiation (q-value-aware
  Accept parsing: application/octet-stream = exact SSZ bytes, JSON =
  {"version","data"} envelope decoded per fork, 406 otherwise,
  Eth-Consensus-Version + Vary headers)
- POST /api/config/settings — generic path-based global settings updates
  keyed by canonical registry keys ({"epbs.bid_subsidy": 1000}); any
  subset in one call, atomic, unknown keys rejected

SSE: new action_plan_updated (PlanChangeEvent) and slot_result_updated
(coalesced SlotResult) events; chain_info now carries slots_per_epoch
for the epoch×slot timetable. Swagger regenerated.
New view rendering the per-slot action plan as a timetable (rows =
epochs, future on top; columns = slots_per_epoch from chain_info; sticky
epoch column, live current-slot highlight). Cells show B/A/R category
chips (custom = filled, disabled = struck) plus a result status dot
(inclusion > failure/suppressed reveal > published > bid/served >
planned-but-nothing-happened > payload-only).

Clicking a future slot opens the edit modal: per-category mode select
(inherit/custom/disabled) with unit-labeled override fields; saves
prefer fine-grained "set" paths so single-field edits never clobber
sibling overrides; 409 freeze conflicts surface as warnings. Past slots
show the frozen applied plan (raw + resolved, forced markers), the full
attempt history, and artifact access — JSON links plus SSZ blob
downloads via Accept negotiation. Shift-click range selection and a
from/to form feed one atomic bulk update.

The view rides the existing SSE connection through a new onStreamEvent
fan-out in useEventStream (no second EventSource), patches in-range
state on action_plan_updated/slot_result_updated, and refetches after
reconnects via a connection-generation counter.
Core-component entries for pkg/action_plan (scheduling authority, freeze
semantics, per-slot enable override contract) and pkg/slot_results
(attempt-aware history, artifact store, won_blocks ownership move); the
new settings/flags; the plan/results/artifact/settings endpoints and SSE
events; slot_artifacts in the state-db section; updated startup sequence
(7b/12b) matching run.go 1:1; testing recipes and 409/artifact-retention
troubleshooting notes.
…eplay cache

Slot-scoped stream events are kept in a server-side replay cache (5-slot
window, 2000-entry cap) and prefilled into each new client's channel at
registration, so the UI restores the slot graph and event log on connect
instead of rendering an empty page. Registration and broadcasting
serialize on one mutex, making replay + live events a single ordered,
gapless stream. Events carry a monotonic seq (seeded from wall-clock
micros to survive restarts); the frontend keeps a high-watermark and
drops already-processed events on reconnect while still applying gap
events. Lifecycle events are tagged with the current slot so they
replay too; per-client state snapshots and the REST-backed plan/result
invalidation events stay uncached.
SlotTimeline sized its window from the moment the page loaded
(firstValidSlotRef = current slot), so the SSE replay burst populated
slotStates but the graph still started with only the current slot.
Seed the window start from the earliest replayed slot instead.
BidsWonView opened its own EventSource in an effect keyed on refetch,
whose identity changed every render. Each refetch re-ran the effect,
reconnected, and the new connection immediately re-received the
replayed bid_won events, refetching again — an endless request loop.
Subscribe to bid_won through the shared connection's onStreamEvent
fan-out instead (stable, and deduped by the seq watermark on
reconnects) and memoize useBidsWon's refetch.
…grid

The backend marshals phase0.Slot as a JSON string, so the SSE handler's
'typeof slot !== number' guard silently dropped every slot_result_updated
event — the grid only ever showed REST-fetched history (which worked by
accident via object-key coercion). Normalize slots to numbers at every
ingest point. Also give BidsWonView its own useEventStream call: pages
mount exclusively, so on the Bids Won tab nothing else established the
shared connection its bid_won subscription feeds from.
A reveal skipped by the action plan (plan_disabled) or a missed
deadline (late) was drawn with the same 'reveal-sent' dot as a
successful reveal, so a disabled reveal looked like it had published.
Give skipped attempts their own muted/dashed marker, thread the skip
reason through the reveal SSE event into the dot tooltip and event log,
and add a 'Reveal Withheld' legend entry.
The inclusion tracker now derives a canonical verdict for every won
slot from the chain itself: on each head event it walks the head's
ancestry (cached by block root) to the block at the won slot — a
different root means the win was reorged out (orphaned); otherwise the
first canonical block after the won slot proves via its committed
parent execution hash whether our payload became canonical or was
missed. Verdicts are re-evaluated for a 16-slot window so reorgs flip
them; every change fires a PayloadStatusEvent, recorded on the slot
result as inclusion.payload_status (pending until the first follow-up;
pre-Gloas wins are canonical immediately) and pushed over SSE.

The Action Plan cell bottom row now renders two dots: left = bid
outcome (included/orphaned/bidding/failed/built/idle), right = payload
canonical verdict (canonical/missed/reorged/pending), shown only for
won slots. Legend and slot detail modal updated accordingly.
Adds a modeless 'build' category to the per-slot action plan with a
reorg_parent_payload flag. When set, payload_builder builds the slot's
payload on the grandparent (n-2) execution payload instead of the
immediate parent: the FCU head hash, the parent block number and the
withdrawals are sourced from the PARENT slot's cached payload
attributes (whose parent is n-2), while every other property — incl.
the beacon parent root — stays from the current slot. The effective
attributes are built once and stored on the Payload, so the bid derived
from it advertises the same parent it built on. Falls back to a normal
build (logged) when the parent slot's attributes are unavailable or
already share our parent.

This is a deliberate parent-payload reorg attempt — rejected by mainnet
forkchoice, useful for exercising reveal/inclusion against a withheld
parent. The build category carries no custom/disabled mode; the flag is
resolved into frozen.Build.ReorgParentPayload and never forces or
suppresses the build decision itself.

UI: a 'Build' section in the slot edit modal (normal parent / reorg
parent, tri-state in bulk), an amber 'P' plan chip on the grid, the
flag surfaced on the frozen-plan detail, and a legend entry.
…I testing

Adds a modeless 'transforms' plan category letting operators rewrite the
JSON of the built payload, the bid message, or the envelope message with
arbitrary jq expressions — for custom builder testing the tool is not
aware of. Backed by a new pkg/jqtransform wrapping itchyny/gojq (pure Go,
environment access disabled, single-output enforced, context-timeout).

Semantics:
- payload: rewrites the built execution payload before it feeds both the
  bid commitment and the reveal (Payload.BlockHash re-synced from the
  result), so both stay consistent.
- bid / envelope: rewrite the MESSAGE just before signing, then RE-SIGN
  with the target-slot fork. Results are validly signed but customized,
  and a bid commitment can deliberately diverge from the revealed payload
  for adversarial testing.

Round-trip is MarshalJSON -> gojq -> UnmarshalJSON into a fresh object
with Version preset (jqtransform.ApplyTyped). Expressions are jq-validated
at plan-update time (400 on bad jq); a runtime transform failure fails
that construction loudly rather than signing the untransformed object.

Live testing: POST /api/buildoor/action-plan/test-transform runs the exact
production gojq against a captured artifact for the slot (bid/envelope
reduced to .message), else the latest buffered artifact, else an
illustrative template. The slot edit modal gains a Transforms section with
per-target expression inputs and a debounced input/output preview; grid
cells show a 'jq' chip and the frozen-plan detail lists applied
expressions.
… JSON

TestTransformResponse.Input/Output were json.RawMessage, so they
serialized as raw JSON objects while the frontend typed them as strings
and rendered them directly in <pre> — React error #31 (object as child)
on Test click. Return pretty-printed JSON as strings so the wire type
matches and the UI renders verbatim.
The window was pinned once at init and never advanced, so a page left
open across epochs kept showing the same range. Derive the window from
the current epoch while 'following' (default) so it moves as epochs
pass; navigating (Older/Newer/jump) pins an explicit range and 'Now'
resumes following. A 'live' badge and highlighted Now button show the
follow state.
fireUpdate fired the first update in a slot's 1s window and silently
dropped every later one, so a burst's FINAL state (e.g. included) never
reached clients — the Action Plan grid sat on the gray baseline until a
manual refetch. Add trailing-edge coalescing: remember the latest
suppressed result and flush it when the window closes, so the final
state is always delivered (leading + trailing). Timer-driven flush is
mutex-guarded and skips a stopped tracker.
…p cooldown

The displayed builder balance = authoritative snapshot balance + a local
adjustment (top-ups add, revealed bids subtract) that bridges the gap
until the epoch snapshot reflects the operation. The adjustment was only
cleared when the snapshot balance VALUE changed, so an idle builder whose
balance stopped changing kept an optimistic 50 ETH top-up credit pinned
to the display indefinitely (showed ~51 ETH vs ~1 ETH real).

Anchor each adjustment delta to its epoch and drop it once the
authoritative snapshot advances past that epoch (PaymentTracker.
ReconcileToEpoch), driven from the always-running inclusion tracker's
per-epoch hook so it works with lifecycle disabled too. Removes the
now-unused balance-change reset.

Re-enabling reconciliation also un-freezes auto-top-up (the phantom
credit had made it think the builder was funded); add an 8-epoch top-up
cooldown so a slow-to-land builder deposit isn't duplicated each epoch.
@pk910 pk910 enabled auto-merge July 14, 2026 13:14
@pk910 pk910 merged commit 0ac1b17 into main Jul 14, 2026
5 checks passed
@pk910 pk910 deleted the pk910/testing-capabilities branch July 14, 2026 13:23
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.

2 participants