Skip to content

feat(r5d): office cycle-scorecard consumer (event-driven, construct-from-correlationId) - #30

Merged
alexnikolskiy merged 12 commits into
mainfrom
feat/r5d-office-cycle-scorecard-consumer
Jul 15, 2026
Merged

feat(r5d): office cycle-scorecard consumer (event-driven, construct-from-correlationId)#30
alexnikolskiy merged 12 commits into
mainfrom
feat/r5d-office-cycle-scorecard-consumer

Conversation

@alexnikolskiy

Copy link
Copy Markdown
Collaborator

R5d — Office Cycle-Scorecard Consumer

Makes the operator see the Lab cycle scorecard. When a Lab research cycle closes, Lab persists a CycleScorecard and emits a cycle.scorecard.built agent event (R5c-lab, trdlabs/lab#180). Office now reacts to that event, fetches the scorecard Markdown from Lab, and posts it into the operator conversation. Behind an off-by-default flag.

Upstream contract: docs/superpowers/specs/2026-07-15-r5d-office-cycle-scorecard-consumer-design.md references lab R5c-lab spec + PR #180.

Why event-driven, construct-from-correlationId

The scorecard is a cycle-level artifact that lands at cycle close — much later than the run-cycle task completes. Lab writes the row before emitting the event, so reacting to cycle.scorecard.built gives a near-zero 404 window and needs no guessing of cycle duration. Office constructs the canonical /v1/cycles/${encodeURIComponent(correlationId)}/scorecard?format=markdown path from a correlationId obtained through the trusted agent-event stream — it never follows a DTO-supplied URL (security invariant). The published LabSummaryLinks.scorecardUrl is declared for contract fidelity but not trusted at runtime. Because the follow anchor can be a chained strategy.onboard task, it's named anchorTaskId.

What ships (5 code commits)

  • Branded path ValidatedScorecardPath + buildScorecardPathgetScorecardMarkdown accepts only the branded type, so no unvalidated string reaches fetch. Guards against a ./.. correlationId collapsing the path via URL normalization (%252E double-encoding — WHATWG URL treats even %2e%2e as a dot segment).
  • TradingLabHttpClient.getScorecardMarkdown — never throws; returns ok | not_found | transient | permanent (sends Accept: text/markdown + bearer, normalized MIME check).
  • ScorecardFollower — event-driven state machine: bootstrap correlationId from the anchor's agent events, recovery probe, single-flight per correlation, coalesced wake-up (an event during a fetch is never dropped), TTL/fetch race-free (TTL mid-fetch defers via expired, never publishes failure alongside an in-flight success), complete() records terminal state before a best-effort publish (a throwing subscriber can't strand a reg), pendingByTask ∪ activeByTask ∪ doneByTask register guard, bounded tombstones, transient-exhausted → idle (TTL is the backstop), permanent & bootstrap-exhaustion → unavailable once, and stop-safety. Publishes the same operator_assistant_message shape as DownstreamBacktestWatcher.
  • Config/envOPERATOR_CYCLE_SCORECARD (off by default, trading-lab mode only, independent of OPERATOR_DOWNSTREAM_BACKTESTS) + OFFICE_SCORECARD_* guards + apps/server/.env.example.
  • Wiring — a unit-testable makeRunCycleFanout helper fans onRunCycleTask to both the backtest watcher and the follower; the follower is constructed independently of the downstream watcher and stopped in shutdown.

Guarantees & non-goals

  • Process-local at-most-once publish (bus + tombstones are in-memory; a restart may re-publish — acceptable, operator content is idempotent). Not a durable exactly-once claim.
  • No trading-lab import (hand-mirrored DTOs; importBoundary test green).

Verification

  • npm run typecheck -w @trading-office/server clean; npm test -w @trading-office/server37 files / 263 tests pass (18 new state-machine tests exercise coalesced wake-up during a real in-flight fetch, TTL-during-fetch, terminal-first-then-throwing-publish, stop-during-bootstrap/fetch; deferred promises + microtask flush for deterministic timing).
  • Subagent-driven: per-task spec+quality review (all Spec ✅ / Approved) + final whole-branch review (opus, Ready to merge: Yes, 0 Critical/0 Important — invariants traced across the whole file). The one real defect found during execution — the plan's %2E dot-guard was WHATWG-collapsible — was caught empirically by the implementer and fixed to %252E.

Rollout (order)

  1. Merge + deploy Lab R5c-lab (feat(r5c-lab): cycle scorecard Markdown render + /v1 endpoint + summary link lab#180) so the endpoint + event exist.
  2. Merge this PR (flag off).
  3. Separate small Lab PR: pass OPERATOR_CYCLE_SCORECARD / OFFICE_SCORECARD_* through to Office in lab/docker-compose.demo.yml (deploy config; deliberately not in this PR since it's a lab-repo change).
  4. Enable the flag in staging, run the end-to-end check (a closed cycle → operator sees the scorecard), then proceed to R6–R9.

🤖 Generated with Claude Code

alexnikolskiy and others added 12 commits July 15, 2026 14:44
…nstruct-from-correlationId)

Consumes trading-lab R5c-lab contract (lab#180). ScorecardFollower reacts to
cycle.scorecard.built, bootstraps correlationId, constructs canonical /v1 path
(branded), fetches text/markdown, publishes to operator chat. Single-flight +
coalesced wake-up + TTL-safe + exactly-once + bounded tombstone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…plete() helper, stop-safety, env surface, honest exactly-once/drift wording

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…h + deferred), missing state-machine tests, activeByTask guard, terminal-first complete(), testable fan-out, Bearer/MIME asserts, deploy passthrough as separate Lab PR

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ersal (defense in depth)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mmaryLinks.scorecardUrl

Deviates from task-1-brief's dot-guard: single %2E percent-encoding of a
"."/".." correlationId does not block new URL() path-collapse under the
WHATWG URL spec (%2e is decoded and still treated as a dot segment).
Fixed by percent-encoding the "%" too in that branch so the literal %2E
text survives normalization undecoded; verified with new URL(...).pathname.
Real Lab correlationIds are UUID-shaped, so this branch never fires in
practice. Details in .superpowers/sdd/task-1-report.md.
…d (brief's %2E was collapsible)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d/transient/permanent

Self-contained never-throws fetch for the cycle scorecard (text/markdown),
mirroring getCompletionSummary's degrade-to-null contract. Consumes the
branded ValidatedScorecardPath from Task 1; MIME is normalized (charset
param stripped) before matching text/markdown.
… wake-up, TTL-safe, stop-safe

Task 3 of R5d: reacts to cycle.scorecard.built agent events, bootstraps
correlationId from the anchor task, fetches scorecard markdown via the
canonical path, and publishes an operator_assistant_message. Single-flight
via pendingByTask/activeByTask/doneByTask guards, coalesced wake-up during
an in-flight fetch, TTL-during-fetch deferral, and a terminal-state-first
complete() so a throwing publish subscriber can never strand a reg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…SCORECARD_* env surface

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…flag-gated, independent of downstream watcher)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alexnikolskiy
alexnikolskiy merged commit 3a56c55 into main Jul 15, 2026
1 check passed
@alexnikolskiy
alexnikolskiy deleted the feat/r5d-office-cycle-scorecard-consumer branch July 15, 2026 14:38
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