Sentinel Suite · NinjaTrader 8 · living document·Docs home →

Sentinel Suite — Design System & Build Framework

The single source of truth for building any Sentinel tool so it stays visually seamless and architecturally consistent. Read this before creating or restyling a Sentinel indicator, strategy, AddOn, or dashboard tab. Update it whenever a convention changes — it is a living spec, not a snapshot.

Proven across: SentinelDashboard, SentinelDeck, SentinelBridge, SentinelCockpit. When a fourth tool needs something new, add the pattern here first, then build to it.


0. The one rule

Cyan is the only accent, and it means LIVE / watching / active. Green and red are reserved for money + direction (P&L, long/short, buy/sell). Everything else is a blue-biased neutral. Amber = caution/advisory. That's the whole language.

If you're reaching for a second accent color, stop — restraint is the identity.


1. Palette tokens (exact hex — never approximate)

Token Hex RGB Use
void #0A0E17 10,14,23 app/window ground, deepest bg
panel #111726 17,23,38 panel surface, caption bar
card2 #0E1420 14,20,32 deeper card / gradient bottom
line #1E2A3D 30,42,61 hairline borders, dividers
dim #1B2536 27,37,54 control / button idle bg
faint #26344C 38,52,76 track bg, gridlines
ink #E9EEF7 233,238,247 primary text
ink2 #AEBACE 174,186,206 labels, secondary text
mute #6C7A92 108,122,146 captions, idle/muted
accent #3FD1E0 63,209,224 cyan — LIVE/watching (the one accent)
up #25D08B 37,208,139 green — money up / long / buy
down #FF5C6A 255,92,106 red — money down / short / sell
warn #F2B34C 242,179,76 amber — caution / advisory breach

Deep button tints for Buy/Sell surfaces (order buttons): Buy #1C8F63#125C41, Sell #B84550#7E2C34.

Translucent recipes (WPF Color.FromArgb / SharpDX alpha): - Cyan selection wash: #553FD1E0 (33% alpha). - Tinted pill bg: accent at ~28/255 alpha; pill border: accent at ~120/255. - Tint(accent, k) = linear blend from void toward accent by k (solid, reads on all skins). Typical k: chip 0.10, pill 0.12–0.16, armed/selected 0.22, big button 0.30.


2. Typography


3. WPF component patterns

Every Sentinel WPF surface declares the palette as fields/consts and builds from shared helpers.

Palette fields

private static readonly Color C_BG=Color.FromRgb(10,14,23),   C_PANEL=Color.FromRgb(17,23,38),
    C_CARD2=Color.FromRgb(14,20,32),  C_BORDER=Color.FromRgb(30,42,61), C_DIM=Color.FromRgb(27,37,54),
    C_LABEL=Color.FromRgb(174,186,206), C_MUTED=Color.FromRgb(108,122,146), C_TEXT=Color.FromRgb(233,238,247),
    C_ACCENT=Color.FromRgb(63,209,224), C_GREEN=Color.FromRgb(37,208,139), C_RED=Color.FromRgb(255,92,106),
    C_AMBER=Color.FromRgb(242,179,76);
private Color Tint(Color a,double k)=>Blend(C_BG,a,k);

1b. Theme (Dark · Light · Silver · Obsidian · Blueprint · Amber) — the on-chart SharpDX layer

SentinelSkin tokens (CInk/CAccent/CUp/CVoid/…) are theme-aware accessors that read the active SentinelSkin.Palette. One SetTheme(t) recolors every card + plot-skin across the whole suite — consumers never change (they still read SentinelSkin.CInk). Card/wash gradients read the CGlassTop/Bot + CWashTop/Bot tokens, so they re-theme too.

Theme Character Void Ink Accent GlowMul
Dark default; navy flight-deck #0A0E17 #E9EEF7 #3FD1E0 1.0
Light soft daylight, not inverted dark #DEE4EC #1B2432 #0C8CA0 1.0 †
Silver brushed graphite/steel mid-tone #262B34 #E8ECF2 #50D6E4 1.0
Obsidian true-black OLED #000000 #F2F5FA #3FD1E0 0.6
Blueprint cyanotype drafting paper #08182F #EAF2FF #5FE3F2 1.0
Amber warm dark / night watch #12100C #F3EDE0 #E8A33D § 0.9

‡ Blueprint lifts the accent (#3FD1E0#5FE3F2): plain cyan sits too close to a blue ground to read as "live". Its platform skin also lifts the grid lines well above the paper — the drafting grid is the theme's signature, not an accident of the recolor.

§ The accent is not required to be cyan. The law is "ONE accent = live/watching"; Amber moves that accent to gold, and consumers never notice (they read CAccent). But moving it forced a second move: Warn is amber (#F2B34C) in every other theme and would have collided head-on with the accent — live and caution must never share a hue. So Amber's Warn is cool blue #6FA8FF, the only theme where Warn is not warm. When you invent a theme, check the accent against Warn and against Up/Down before you fall in love with it.

† Light's glow-down is baked into the IsLight branches (tighter radii and lower alpha), so its GlowMul stays 1.

Each theme is a deliberate design, never a brightness knob. Light uses cool off-white grounds (never glaring white) and DEEPENED accents so cyan/green/red survive on a light ground. Obsidian is not "Dark, darker": its ground ramp is near-neutral (a navy tint over #000 reads as haze), its ink is lifted (text on pure black needs less weight to feel bright), and its glow is dialled back — a bloom halo that reads as light on navy reads as smear on true black.

Palette.GlowMul scales every glow/halo alpha in the Painter (Dot · Pill bloom · HistoBar · GlowLine). ⚠ It is a class field, so an unset Palette silently defaults to 0 (no glow at all) — every palette must set it explicitly.

Adding a theme touches exactly four places: a Palette instance · a Theme enum member · a word in TryParseTheme · its skin background hex in SkinBgTheme.

Theme resolution — the glue

Painter.Begin calls MaybeRefreshTheme() (throttled ≤2s), which resolves in priority order: 1. Manual pin<Documents>\NinjaTrader 8\Sentinel\theme.txt containing a theme word (dark/light/silver/obsidian) pins the on-chart theme and wins over everything. 2. The glue — otherwise (auto/absent) the on-chart theme follows the active platform skin, read from ChartControl.ChartBackground in the app resources. So one skin switch re-themes the whole suite within 2s, no F5.

Skin backgrounds, which are the glue's keys: Sentinel #0F1524 · Light #E6EBF1 · Silver #2A2F38 · Obsidian #000000 · Blueprint #0A1E3C · Amber #16130D.

The glue matches the skin's exact background hex (SkinBgTheme), not its luminance. This matters: luminance cannot separate two dark themesSentinel (#0F1524, lum ≈ .08) and Sentinel Obsidian (#000000, lum 0) sit in the same band, so a luminance-only classifier resolved Obsidian as Dark. Luminance survives only as a fallback for non-Sentinel skins (Midnight, Slate Dark, White-Ice…). Keep each skin's ChartControl.xaml background hex in sync with SkinBgTheme — both sides carry a comment saying so.

Scope. The token system covers the on-chart SharpDX layer. WPF-hosted surfaces (Deck, Dashboard, Cockpit) read the K* WPF-Color accessors and re-theme at build/open time, not live — the Deck's header theme button rebuilds its panel for exactly this reason. Each theme also needs a parallel platform skin folder (templates\Skins\Sentinel, … Light, … Silver, … Obsidian) for the price panel, Control Center and tickets. See §4c for the price panel.

⚠ Before declaring a tool "themed", grep it for hardcoded RC( / Color.FromRgb( / Color4( — the Deck has three themed surfaces (WPF panel, SharpDX risk card, order lines) and each was missed once.

Header (every panel/window opens with this)

Cyan eye dot with a DropShadowEffect glow (Color=accent, BlurRadius 9, ShadowDepth 0, Opacity .85) · wordmark (SENTINEL ink bold + tool name in mute) · a cyan-tinted version chip (Tint(accent,.10) bg, accent border @90α, accent text). See SentinelDeck.BuildDeck / dashboard top bar.

Section labels & collapsible sections

Buttons

Chips / pills / state badges

Translucent colored pill: bg Color.FromArgb(28,c), border Color.FromArgb(120,c), radius 7–9, colored SemiBold text. State badges (MANAGED-BY, ACTIVE/HALT) tint by state via Tint(col,.12).

Dashboard cards (reusable set in SentinelDashboard)

Chart primitives (reusable WPF charts in SentinelDashboard, v1.1.4 — dataviz method)

TextBoxes

C_DIM bg, C_TEXT fg, C_BORDER border, Consolas 12px, VerticalContentAlignment=Center.


4. On-chart risk card (SharpDX / Direct2D)

The signature "glass instrument card." Reference: SentinelDeck.OnRender (with UpdateCardData for the cached display values — never read Value[] in OnRender).

Don't hand-roll any of the above — use the shared framework (§4b). §4 is the recipe the framework implements; reach for it only when extending SentinelSkin itself.


4b. The indicator framework — SentinelSkin (USE THIS)

AddOns/SentinelSkin.cs is the single library every Sentinel indicator/strategy draws with, so they're cohesive by construction. It packages the whole §4 vocabulary + the §1 palette + §2 fonts. Namespace NinjaTrader.NinjaScript.AddOns.Sentinel — add using NinjaTrader.NinjaScript.AddOns.Sentinel;.

Live reference implementation: CompressionBase_v1_3_0 (in Indicators.Sentinel) — its OnRender glass card (header dot + title + state pill + coil-vs-threshold Track + mono stat rows) is the pattern to copy for retrofitting any plain Draw.TextFixed/hand-rolled on-chart readout. Retrofitted (all Sentinel-homed): CompressionBase_v1_3_0, ADXPro_v1_2_0 (gauge hero + DI tracks + ADX sparkline + regime publish), Eye_v1_1_0 (perf grid), SignalExcursionRecorder_v1_3 (a previously-headless recorder that gained a card: REC/IDLE pill · tracking-count hero · regime+ADX track · BG/FC/OBR tally · latest-record MFE/MAE). All place their card via CardLayout (below) so they never overlap, and all carry the label remover (below). Still to sweep: any remaining tool with a plain Draw.TextFixed readout — give each a CardCorner + CardLayout + the label remover. FORK GOTCHA: when you cp a versioned indicator to fork it, the copy carries its NT generated #regions and running NT appends more → CS0111/CS0102. Strip ALL generated regions to EOF right after the copy (head -n <lastRealLine> file > tmp && mv); NT regenerates one clean copy on F5.

Palette (no local color fields anymore): SentinelSkin.CVoid/CPanel/CCard/CLine/CDim/CFaint/CInk/ CInk2/CMute/CAccent/CUp/CDown/CWarn (SharpDX Color4 for OnRender) and WVoid…WWarn (WPF Color for hosted panels). RC(r,g,b,a), Alpha(c,a), W(r,g,b). Fonts: SentinelSkin.FSans / FMono.

The Painter — hold one as a field, Begin() each frame, Dispose() in Terminated:

private readonly SentinelSkin.Painter _sp = new SentinelSkin.Painter();

protected override void OnRender(ChartControl cc, ChartScale cs) {
    base.OnRender(cc, cs);
    if (RenderTarget == null) return;
    _sp.Begin(RenderTarget);
    var r = _sp.Card(x, y, 300f, 148f, active ? SentinelSkin.CLine : SentinelSkin.CWarn);   // glass card → inner rect
    _sp.Dot(r.Left + 4f, r.Top + 8f, active ? SentinelSkin.CAccent : SentinelSkin.CWarn);    // live glow dot
    _sp.Text("SENTINEL EYE", r.Left + 15f, r.Top - 1f, 160f, 18f, SentinelSkin.CInk, 12f, semibold:true);
    _sp.Pill(state, r.Right, r.Top - 2f, dotColor);                                          // state pill
    _sp.Money(r.Left - 2f, r.Top + 40f, pnl, pnl >= 0 ? SentinelSkin.CUp : SentinelSkin.CDown);  // kerned hero
    _sp.Track(r.Left, r.Top + 96f, r.Width, frac, SentinelSkin.CAccent);                     // progress bar
    _sp.Gauge(r.Left + 44f, r.Top + 110f, 34f, rFrac, SentinelSkin.CFaint, SentinelSkin.CAccent);
    _sp.Sparkline(r.Left, r.Top + 70f, r.Width, 22f, history, SentinelSkin.CUp);
    _sp.Divider(r.Left, r.Top + 120f, r.Right);
    _sp.End();   // releases per-frame gradients/geometries
}
protected override void OnStateChange() { /* … */ if (State == State.Terminated) _sp.Dispose(); }

B(color) gives a cached brush if you need raw draws. The Painter caches brushes (per RenderTarget) + text formats + a round stroke, and owns its DirectWrite factory — no per-frame allocation beyond the gradients/geometries End() frees. Any on-chart Sentinel drawing goes through the Painter so the look stays identical everywhere; if you need a new primitive, add it to the Painter, not to one tool.

Chart data-series / candles + drawn-text fonts are handled by the Sentinel skin, not per tool: ChartControl.UpBrush/DownBrush = candle bodies, Stroke/Stroke2 match the bodies (set all four together), selection = cyan, chart text = crisp ink-grey (Docs/[[sentinel-skin]]). Current candle colors (2026-07-05, user pick): up = teal #FF009999, down = grey #FF8E8E8E (was green/red #FF25D08B/#FFFF5C6A). Skin color edits load only when the skin (re)loads — re-select the skin or restart NT; a NinjaScript F5 does NOT reload skins. For a saved data-series template, NT stores UpBrushSerialize/DownBrushSerialize inside the ChartStyle — set those to the same hex if you export one, but the skin default is the cohesive path.

Chart right-side margin is NOT a skin resource. Skins carry only brushes/pens/fonts/UI-margins — there is no ChartControl key for the price-panel right margin (confirmed by grep). It is a per-chart property: right-click chart → Properties → Right side margin. To make it global-ish, set it on a chart then right-click → Templates → Save As → "Default" so every new chart inherits it (e.g. 350px to keep the stacked glass cards clear of price action). If a true app-level default exists, it lives in NT's user config, not the skin.

Card placement — SentinelSkin.CardLayout (USE THIS; never hardcode the corner). Cards from different tools all defaulting to ChartPanel.X + W - cw - 12 collide (two cards → one on top of the other). Instead ask the shared registry for the rect each frame:

var slot = SentinelSkin.CardLayout.Place(this, ChartPanel,
    ChartPanel.X, ChartPanel.Y, ChartPanel.W, ChartPanel.H, CardCorner, cw, ch);   // → SharpDX.RectangleF
var r = _sp.Card(slot.X, slot.Y, cw, ch, edge);

Retrofitting an existing indicator (SentinelEye, SignalExcursionRecorder, CompressionBase, …): delete its local color consts + hand-rolled OnRender card, add the _sp field + Dispose(), redraw via the Painter, and place the card with CardLayout (+ a CardCorner property). Bump the version + changelog per §7.

4c. The Sub-panel PLOT STANDARD (bring histograms/lines up to the card's material)

A gorgeous card floating over stock NT plots (flat bars in raw WPF brushes, flat-black panel, default grid) is the #1 vibe-killer. Fix it by rendering the plots themselves through the Painter, in the SAME OnRender frame as the card. The key trick: NT draws its stock plots BEFORE OnRender, so an OPAQUE PanelWash drawn first in OnRender covers them — which also defeats the chart's saved-plot-color override (no remove/re-add needed). Reference impl: SentinelWAE_v1_0_0 (RenderPlotSkin).

New Painter primitives (the sub-panel counterpart to the card set): - PanelWash(x,y,w,h) — the navy glass gradient behind everything. Draw FIRST (after base.OnRender). - RegimeShade(x,y,w,h,col,alpha) — a faint full-panel state wash (cyan live / green-red bias). Low alpha. - Baseline(x0,x1,y,col) — a themed zero/reference line (turn NT's own grid OFF in SetDefaults). - HistoBar(cx,yZero,yVal,halfW,col,glow) — a card-material histogram column: vertical gradient (bright tip → translucent base), soft-rounded ends, optional glow. Palette: CUp/CDown (NOT raw green/red), dim the alpha for the "weakening" tone instead of clashing lime/orange. - GlowLine(pts,col,width,glow) — an oscillator/reference line as a soft glow underlay + crisp stroke.

The OnRender recipe (chart-space; read series by absolute bar index — barsAgo throws in render):

base.OnRender(cc, cs);
if (RenderTarget == null || ChartPanel == null) return;
_sp.Begin(RenderTarget);
try { if (SentinelPlotSkin) RenderPlotSkin(cc, cs); } catch { }   // wash → shade → baseline → histobars → lines
try { if (ShowCard) RenderCard(); } catch { }                     // card LAST, on top
_sp.End();
// inside RenderPlotSkin: for idx in [ChartBars.FromIndex..ToIndex]:
//   x = cc.GetXByBarIndex(ChartBars, idx);  y = cs.GetYByValue(Values[i].GetValueAt(idx));

Expose a SentinelPlotSkin toggle — deliberately NOT [NinjaScriptProperty] (a plain [Display] get/set serializes without a constructor-param / codegen churn), default ON, so a user can fall back to stock plots.

Adopted (2026-07-07): SentinelWAE (reference) · ADXPro_v1_2_0 (wash + per-bar regime bands that supersede its muddy BackBrushes + glowing ADX/DI lines + trigger/strong reference lines) · WoodiesCCIPro_v1_0_0 (wash + bottom trend ribbon + glowing Main/Turbo lines + 0/±100 lines) · BuySellVolumePressureMountain_v1_0_0 (wash + two-sided gradient histobars). Each keeps a SentinelPlotSkin toggle. Still stock: VolEnvelope (a price-panel overlay/cone — different case), and any newly ported panel.

The PRICE panel is different — it lives in the skin, not an indicator. You can't PanelWash the price panel: OnRender draws over the candles, so an opaque wash there would hide them. The candle-safe home for the price-panel background is the platform skin's ChartControl.ChartBackground (drawn behind the bars) — set it to the same navy as the wash (templates\Skins\Sentinel\ChartControl.xaml, currently #0F1524, between CPanel and CVoid). NOTE: a skin edit needs a skin reapply / NT restart to take effect — it is NOT picked up by an F5.

MANDATORY on EVERY Sentinel indicator — the label remover

The chart is a flight instrument; NT's default top-left name-label is clutter. Every Sentinel indicator hides it by default with a trader toggle to restore it. NT draws the chart panel label from the indicator's Name property (NOT ToString() — that override is inert here; VERIFIED 2026-07-05 against the shipped LabelRemover.cs, which blanks other indicators via indicator.Name = ""). So blank Name at DataLoaded when the toggle is off:

[NinjaScriptProperty]
[Display(Name = "Show indicator label", Description = "Show NinjaTrader's chart name label. Sentinel default = OFF (clean chart).", GroupName = "Sentinel", Order = 100)]
public bool ShowIndicatorLabel { get; set; }
// SetDefaults:  Name = "<Thing>_vX_Y_Z"; … ShowIndicatorLabel = false;
// DataLoaded (FIRST line):  if (!ShowIndicatorLabel) Name = string.Empty;

Name is set to the real identity in SetDefaults (picker/serialization see it) and blanked at DataLoaded (the chart label reads the runtime value → hidden). Toggling the property re-runs OnStateChange, so it takes effect on Apply. Default OFF = clean chart. (Trade-off: when OFF the indicator also reads blank in the Indicators-dialog "Configured" list — acceptable; identify by the picker folder.) Applied to CompressionBase_v1_3_0, SignalExcursionRecorder_v1_3, ADXPro_v1_2_0, Eye_v1_1_0, Deck_v0_2_1; required on all future ones.


5. NinjaScript architecture patterns

Which type?

Hosting a panel in the ChartTrader sidebar (the proven recipe)

An indicator/strategy injects a WPF panel into ChartTrader's Content Grid:

_ctChart      = Window.GetWindow(ChartControl.Parent) as Chart;
var ct        = _ctChart.FindFirst("ChartWindowChartTraderControl") as ChartTrader;
_ctTraderGrid = ct.Content as Grid;
// build hudStack → wrap in ScrollViewer → InsertPanel(): add a Star RowDefinition, Grid.SetRow,
// SetColumnSpan, add to grid.Children. Teardown removes the child + row.

Account + instrument come from the native selectors — do NOT build your own pickers: GetChartTraderWindow().FindFirst("ChartTraderControlAccountSelector") as AccountSelector.SelectedAccount; "ChartWindowInstrumentSelector" as InstrumentSelector.Instrument. (Wrap reads in ChartControl.Dispatcher.Invoke.)

Account-level (unmanaged) orders — the manual/deck path

An indicator that submits its own orders fully owns them (no managed-position desync). Signature:

acct.CreateOrder(instr, OrderAction, OrderType, OrderEntry.Manual, TimeInForce, qty,
                 limitPrice, stopPrice, oco, Name+"_tag", Core.Globals.MaxDate, null);
acct.Submit(new[]{ order });   // acct.Cancel(orders) to pull working orders

Managed-framework landmines (do NOT relearn)


6. Sentinel integration seam (SentinelCore)

Reference SentinelCore (AddOns/SentinelCore_v1_0_0.cs) — all statics, namespace ...AddOns.Sentinel. Add using NinjaTrader.NinjaScript.AddOns.Sentinel;.

Call Returns Use
CanEnter(instr, acct, out reason) bool composite entry gate (kill+scoped-kill+feed+governor+session+rollover+news)
CanActInstrument(instr, acct, out reason) bool copier/mirror gate
GetGovernorState(account) GovernorState day P&L / cap / status (.DailyPnl/.Cap/.Allowed/.Status)
TradingAllowedToday(acct) / InAccountSession(p,out r) bool governor / session
InstrumentKillEngaged(instr) · RolloverBlocked · NewsLockoutActive bool scoped states
SizedQuantity(acct, baseQty) int profile-scaled size

The Order Gate is the single pre-submit choke point (Docs/SENTINEL_HARDENING_FRAMEWORK.md). New order paths route through it instead of raw CanEnter.

Call Returns Use
GateEntry(acct, instr, qty, stopTicks=0, riskDollars=0, instr=null) GateDecision {Level (Clear/Advisory/Hard), Reason, Size} THE entry gate — risk-sizes + classifies. Pass stopTicks+riskDollars+instr to size by $-risk.
SizeForRisk(acct, instr, stopTicks, riskDollars) int contracts for a $-risk (0 = can't afford a 1-lot)
TickValue(instr) double $ per tick (PointValue×TickSize)
NoteOrderSubmitted(account) · SetOrderGuards(maxQty, perWindow, sec) feed / tune the fat-finger rate guard (call NoteOrderSubmitted after every submit)
Ledger.Order(acct, instr, action, type, qty, px, tag) · Ledger.Action(kind, acct, detail) · Ledger.Fill(acct, instr, action, qty, intended, fill, tickSize, tag) WRITE (async) to the daily event stream. Fill carries intended-vs-actual price → adverse slip ticks (feeds the Slippage view); call it from OnExecutionUpdate (realtime only). See the intended contract below — getting it wrong produces a number that looks like a measurement and is not.
Ledger.ReadRecent(days) · Ledger.ReadDay(date) · Ledger.Parse(line) List<Ledger.Entry> / Entry READ the stream → typed rows (Evt/Account/order:Instrument,Action,Type,Qty,Price,Tag/action:Kind,Detail; TimeLocal,IsOrder,IsAlert,IsCritical). The Dashboard Journal tab + future audit/slippage are VIEWS of this — never build a 2nd journal.
Ledger.Dir · Ledger.FileFor(date) string ledger folder / a day's JSONL path
State.Save(key,json) · State.Load(key) · State.Clear(key) · State.Age(key) · State.SaveMap(key,map) · State.LoadMap(key) — / string / Dictionary intended-state store: keyed atomic blob (<SettingsDir>\State\<key>.json) so a tool's arm-state (trail high-water / BE-armed / active stop) survives a restart. Save on change, Clear on flat, Load+reconcile on restart. Key by tool identity, e.g. "GTrader21|<acct>|<instr>".
Alerts.Critical(title, detail) · Alerts.Info(...) · Alerts.Recent(n) · Alerts.Raised event 2-tier alerts (Critical rare by design). Consumers: dashboard Risk tab (Recent), SentinelAlertService (sound + optional push shell command, config Sentinel\Alerts.conf).
HardEnforceArmed(acct) · GovernorResetHour / GovernorResetLabel bool/int/string opt-in hard auto-flatten flag · daily reset clock

⚠ The intended contract — what you MUST pass for slip to mean anything (2026-07-28)

Ledger.Fill computes slip = (fill − intended), adverse-signed. intended is therefore the price you were entitled to expect, and it is a different quantity per order type:

order type intended what slip then measures
Limit / StopLimit order.LimitPrice price improvement / none — a limit fills at or better
StopMarket order.StopPrice stop slippage — how far past your trigger the market went
Market the live quote on the side you are crossing, captured at submission: GetCurrentAsk() when buying, GetCurrentBid() when selling crossing cost — the spread you paid to fill now

Two anti-patterns, both of which shipped and both of which produced unusable data:

Threading: capture the quote where it is legal to read it. A Strategy can call GetCurrentBid/Ask() on the data thread; an Indicator whose order path runs on the WPF UI thread (the Deck) must instead latch bid/ask in OnMarketData and read the latch. Key the stamp per order when a tool has several market-order sites, or one fill will consume another's reference and report a fabricated cost.

Scope: the reference comes from live market data ⇒ realtime only, and forward-measuring — it recovers nothing about fills already taken.

Fail-open vs fail-closed on the Gate (the core policy split): - Manual tools (SentinelDeck) = fail OPEN — surface a Hard reason loudly but never block a human (they must always be able to exit). if (gate.IsHard) StatusLoud(reason); submit anyway; - Automated tools (SentinelBridge, Copier) = fail CLOSED — enter only on gate.IsClear. A code exception still fails open (resilience ≠ gate bypass). - Exits never gate — always allow flatten/close.

Publish/consult registries exist for kill-switch, feed-watch (ref-counted), Eye verdicts, VolEnvelope regime, ADX regime (v1.2.0 — SetAdxState/GetAdxState/AllAdxStates + AdxState; ADXPro publishes trend strength + bias as INT -1/0/1, .TrendOn/.Building/.Aligned(dir)), trend line (v1.3.0 — SetTrendState/GetTrendState/AllTrendStates + TrendState; SentinelTrend publishes trailing direction INT -1/0/1 + line price + signed distance ticks + bars-in-trend + .Flipped, .IsUp/.IsDown/.Aligned(dir)), liquidity walls (v1.4.0 — SetLiquidityState/GetLiquidityState/AllLiquidityStates + LiquidityState; LiquidityWalls publishes absorption z-score + side INT -1 support-below/0/1 resistance-above + nearest wall above/below price + distances, with .ResistanceAbove/.SupportBelow/.NearWall(ticks)/.BlocksEntry(dir,ticks) so a consumer can veto entries into a wall), CCI trend (v1.5.0 — SetCciState/GetCciState/AllCciStates + CciState; WoodiesCCIPro publishes persisted Woodies trend state INT -2..+2 + Main/Turbo CCI + slope + last entry signal, with .Bias/.Strong/.TrendOn/.Aligned(dir)), brick/bar-state (v1.6.0 — SetBrickState/GetBrickState/AllBrickStates + BrickState; the Sentinel bartypes publish adaptive ATR + brick direction INT -1/1 + offsets + live tick-countdown, with .IsUp/.AtrTicks(ts)/.Aligned(dir)), the Council verdict (v1.7.0 — SetCouncilState/GetCouncilState/AllCouncilStates + CouncilState; see §6c), session clock (v1.8.0 — SetClockState/GetClockState/AllClockStates + ClockState; Clock publishes session phase INT 0/1/2/3 + mins-since-open/to-close + kill-window), participation (v1.9.0 — SetParticipationState/GetParticipationState/AllParticipationStates + ParticipationState; Participation publishes relative volume + z-score + climax/dry-up), structural levels (v1.10.0 — SetLevelState(object)/GetLevelState + LevelState; Location publishes VWAP+bands/PDH-PDL/OR/IB/session H-L + nearest level, .InPath(dir,atr)), MTF alignment (v1.10.0 — SetMtfState(object)/GetMtfState + MtfState; MTF publishes higher-TF consensus Bias/AlignmentScore/AllAgree), compression breakout (v1.11.0 — SetCompressionState(object)/GetCompressionState + CompressionState; CompressionBase publishes breakout pulse + held BreakDir + coil), intermarket (v1.12.0 — SetIntermarketState(object)/GetIntermarketState + IntermarketState; Intermarket publishes a configurable correlated-instrument Lean), WAE momentum (v1.13.0 — SetWaeState/GetWaeState + WaeState; SentinelWAE publishes a Waddah-Attar momentum-breakout vote), god-reversal (v1.14.0 — SetGodReversalState/GetGodReversalState + GodReversalState; SentinelGodReversal publishes a candle-grammar reversal trigger), flux order-flow (v1.31.0 — SetFluxState/GetFluxState + FluxState; the SentinelFlux order-flow-imbalance BAR TYPE publishes a signed tape-imbalance vote — the first genuinely ORTHOGONAL axis), session CVD (v1.43.0 — SetCvdState/GetCvdState + CvdState; SentinelCVD publishes cumulative volume delta on ANY bar type — Slope/SlopeZ/Dir for direction, Divergence for absorption, and Efficiency = ticks of price per 1,000 contracts of net aggression, i.e. market impact. TickBacked says so when signing degraded to a bar-body proxy rather than pretending), buy/sell pressure (v1.45.0 — SetPressureState/GetPressureState + PressureState; BuySellVolumePressureMountain publishes BuyPct/SellPct/Delta/Dir/DomRatio/Strong/Divergence/TickBacked. It had been ported with a glass card and NO seam since 2026 — computing an order-flow opinion nothing could consult, which is precisely the §9 item 6 miss this list exists to prevent), fleet plan (Arc), config-use, governor, profiles — see SentinelCore (internal v1.48.0) for the setters when a tool should broadcast state. New Profiles.conf keys: resetHour=17 (governor daily reset hour, local) · hardEnforce=true (arm auto-flatten at the loss stop).

6b. Signals as PLOTS — the generic consumer seam (Deck SIGNAL ARM)

Two ways a tool broadcasts a signal; prefer whichever the consumer needs, but a plot is the universal one: 1. Hidden PLOT (universal — any consumer, no coupling): a signal-emitting indicator exposes its signal as a plot, e.g. Signal[0] = +1/-1/0. Make it invisible + non-scaling: AddPlot(new Stroke(Brushes.Transparent,1f), PlotStyle.Line, "Signal") and set IsAutoScale = false so the ±1 values never render or squash the price panel. Reference: CompressionBase_v1_3_0 writes its breakout to Signal inside MarkBreak. Any tool then reads it generically — the Deck SIGNAL ARM discovers ALL loaded indicators' plots (ChartControl.Indicatorsind.Values[i], names via ind.Plots[i].Name) with zero hardcoding, and arms / auto-fires off the chosen one. 2. SentinelCore publish (Sentinel-aware only): pre-digested state consumed by name, e.g. the Eye's SetEyeVerdict, ADX SetAdxState, Trend SetTrendState. Use when the signal is a rich verdict, not a plottable scalar.

Consuming another indicator's plots — the rules (memory nt-consume-indicator-plots): resolve the source ref on the UI thread (cache it), READ .Values on the data thread (never enumerate ChartControl.Indicators off the UI thread). A one-bar PULSE plot is race-prone — read the just-closed bar (barsAgo=1) and re-check every tick (fires the bar after the signal, non-repaint). Never write ind.Plots.Count (method group → CS0019, and headless won't catch it) — use the indexer. Sentinel tools blank Name (label remover) → name a source by GetType().Name.

6c. The Council — the confluence arbiter ("the brain") (SentinelCore ≥ v1.7.0)

Council_v1_0_0 (a read-only chart indicator, NO ORDERS) is the one tool that CONSUMES every sensor seam and FUSES them into a single verdict, then re-publishes it as CouncilState for everything else to consult. It is the top of the consult chain: sensors publish → Council fuses → strategies/Bridge/Deck consult the Council.

6d. Signal collection — the orthogonal axes (planned seams, build in dependency order)

The Council is built to pick these up automatically as each publishes a …State seam. Rule for every new axis: publish a clean …State seam AND record it to the Ledger on every fire, or Lens can never tell whether it helped. 1. ClockState — session phase / minsSinceOpen / minsToClose / dayOfWeek / inKillWindow. A modulator (scales weights), not a voter. Nearly free; build first. 2. EventState / event vetoalready has a live consumer: SentinelRiskService reads Sentinel\News.conf (YYYY-MM-DD HH:mm | Event | scope | beforeMin | afterMin) → SentinelCore.SetNewsLockouts → folded into CanEnter and consulted by the Council. To automate, land EconomicCalendar.py's block_windows into News.conf. Consume rules: (a) freshness guard — treat a stale/missing calendar (date ≠ today) as fail-to-caution, never trust yesterday's windows; (b) the script's directional bias_score is EQUITY (MNQ) specific — do NOT feed it to a gold (GC/MGC) strategy; only the blackout windows are universal; (c) times are ET wall-clock — convert ET→session TZ (DST-aware). 3. ParticipationState — time-of-day-normalized RVOL + cumulative-delta divergence + climax/dry-up. Reuses existing delta + the State blob store (persist the per-minute volume curve). 4. LevelState + MtfState + InternalsState — one shared multi-series "Context" host (all need AddDataSeries). Location = VWAP+bands / PDH-PDL / opening range / IB / session H-L / volume-profile POC-VAH-VAL, ATR-normalized distances. MTF = bias on the 1/5/15/60/240 ladder, anchored to SentinelTrend's trend definition. Internals = DXY+yields (gold) or $TICK/$ADD/$VOLD/$TRIN (ES) — PARKED on feed availability. 5. FluxState — ✅ SHIPPED (v1.31.0): the ORDER-FLOW axis. The SentinelFlux bar type (BarsPeriodType 212203) closes bars on accumulated signed order-flow imbalance (López de Prado information-driven bars, TBars-stabilised) and publishes a tape-sourced FluxState vote — the suite's first genuinely orthogonal axis, wired into the Council as the FLUX voter (+ an absorption-damp modulator). This is the piece the §6c "agreement, not confirmation" caveat was waiting on. Parked lower still: book/spread microstructure, VIX/vol-term-structure regime — note the order-flow slice is now delivered by Flux; what remains parked is full book/spread microstructure and the VIX regime.

6e. Helm — the interdiction seam (the publish/consult idiom pointed the OTHER way) (SentinelCore ≥ v1.34.0)

Everywhere else in §6 a sensor publishes state and a consumer reads it. Helm inverts that: a HUMAN publishes an intent addressed to a RUNNING automated actor, and the actor consumes and executes it with its OWN order handles. Helm is the interdiction layer — a human grabs the wheel of a running actor without stopping it. It completes the trio: Deck (you drive) · Bridge (it drives) · Helm (grab the wheel). Helm owns NOTHING — it never touches an order; it publishes an HelmIntent and the actor stays the sole owner of its position and exits.

Call Returns Use
SetHelmIntent(instanceKey, HelmIntent) PUBLISH an intent (human side). Auto-fills Id/IssuedUtc/ExpiryUtc (default TTL 120s); FIFO-queued per instanceKey.
TakeHelmIntent(instanceKey) HelmIntent CONSUME (idempotent) — pops the next live intent, drops expired ones, returns null when empty. Drain in a LOOP each pass.
PendingHelmIntents(instanceKey) int non-consuming count of live queued intents.
SetHelmState(instanceKey, HelmState) · GetHelmState(key, maxAgeSec) · AllHelmStates() · ClearHelm(key) — / HelmState / list / — the actor publishes HelmState BACK (position/stop/target/paused/override/status) so a surface renders reality; ClearHelm on teardown.

6f. The runtime layer — faults, generations, replay, config cascade (SentinelCore v1.37.0 → v1.46.0)

Five Core releases added infrastructure that every tool is expected to use. None of it changes what a tool does; all of it changes what a tool can tell you when something goes wrong.

⛔ A seam is LAST-VALUE-WINS — so an EVENT gets a queue, not a seam (v1.46.0)

This is the one place the …State seam protocol is the wrong answer, and it must be stated or it will be re-derived wrongly. Every seam in §9 is a level: the newest write replaces the previous one, which is exactly right for "what is ADX saying right now." A signal FIRE is not a level — it is an event, and two fires inside one bar under last-value-wins would silently drop one. A dropped fire is not a degraded reading; it is a decision the corpus will never know happened.

NoteSignalFire(scope, dir, tag, isHistorical, …) / DrainSignalFires(scope) is a bounded event queue with counted drops. Four properties are load-bearing, and each closes a hole this project has actually been bitten by:

property why
Historical fires are REJECTED AT THE DOOR, not queued a historical fire queued and drained at realtime is lookahead contamination — the same class as the UpdatedUtc as-of hole (§9)
BOUNDED, with drops COUNTED an unbounded queue behind a stalled consumer is a leak; a silent bounded one is a lie
Stale fires expire on drain an event resurrected minutes later is graded against the wrong market
SignalFireStats() exists a strategy that records nothing must not be indistinguishable from a strategy that never fired — the fail-open-abstention lesson (§9), applied to intake

Rule for a new tool: publish a …State seam for what you are; call NoteSignalFire for what you did. SentinelKeel is the reference caller; SentinelExcursionRecorder (v2.4.0+) is the reference drain.

Swallow — the mandatory catch idiom (v1.41.0, SentinelCore.Foundation.cs)

catch { } is no longer acceptable anywhere in the suite. A 2026-07-25 audit found ~350 empty catches and identified them as the proven mechanism behind most expensive bugs here — the BRK/FLUX hunt, 160 false NAKED POSITION criticals, the Eye never loading, the Deck plot race. The intent was always right (never throw into a bar or order path); the defect was that "don't propagate" had been built as "don't record."

Call Returns Use
Swallow(tag, ex) record a swallowed exception. Never rethrows, so a migrated catch { } keeps its exact runtime behaviour
Faults() Dictionary<string,long> per-tag counts — for the Cockpit / health board
FaultTotal() long one number for a stat tile
catch { }                                   // ⛔ never
catch (Exception _sx) { SentinelCore.Swallow("Deck.Fill", _sx); }   // ✅ always

Generation beacon — telling DECOUPLED apart from ABSENT (v1.40.0)

An F5 recompile orphans bars-type instances: the chart keeps executing the pre-F5 assembly and publishes into that assembly's static seam store, while post-F5 consumers read the new one. The write succeeds into an orphaned store, every guard reads healthy, and the consumer just sees the seam missing. Only an NT restart fixes it — a chart reload does not. See [[f5-decouples-bartype-seams]].

Call Returns Use
Generation string this assembly generation's id; changes on every compile/reload
Beacon(scope, kind) publisher heartbeat, called where the seam is published. Throttled 1 write / 5 s / key — safe per-tick
BeaconForeign(scope, kind, maxAgeSec = 120) string | null consumer check: "gen ab12, 3s ago" when a different generation is publishing this seam, else null

Read the return value carefully — the distinction is the entire point. A non-null answer means the sensor is alive but decoupled ⇒ restart NT. null means genuinely absent / not loaded ⇒ a different problem with a different fix. It survives the reload because it lives in an AppDomain data slot, not a static field.

ReplayMode — the replay-only guard bypass (v1.38.0)

ReplayMode is true when Sentinel\replay.on exists. Globals.Now is wall-clock during Playback, so the bars-type freshness guard would otherwise reject every replayed bar — meaning BRK could never vote in any replay bake. With the flag on, those guards stand down.

⚠ NEVER leave replay.on on a box that trades live — replayed bars will stamp as fresh.

ConvictionState — the flow-confirmed bias seam (v1.37.0)

SetConvictionState(...) / GetConvictionState(scopeOrInstrument, maxAgeSec). Published by SentinelDrift, consumed by the Council's CVB voter. Carries Bias (voted only when the per-brick signed tape delta confirms the brick direction, |agree| ≥ 0.15, else abstains), plus FlowDir / BrickDir / Conviction / Divergence. It is the suite's order-flow-confirmed direction, as distinct from price-derived direction.

Config cascade — RosterIO / LaneIO (v1.39.0, SentinelCore.SystemBuilder.cs)

Both resolve scope ▸ instrument ▸ global, first match wins:

Models\<INST>\<barTag>\Roster.conf     most specific
Models\<INST>\Roster.conf              every bar type for that instrument
Models\Roster.conf                     global fallback

Lane.conf used to be most-specific-only, so a chart on an unused bar type found no file, silently kept its F6 ConvictionFloor, and recorded almost nothing — indistinguishable from every sensor being dead. Making the two files behave identically is what made new chart ▸ pick a bar type ▸ load ▸ run possible.


7. Naming & versioning

⚑ FEDERATED NAMING LAW (RATIFIED 2026-07-07 — this is the single source of truth; supersedes the earlier "drop the prefix" decision). A novice must be unable to confuse a Sentinel-plumbed tool with a stock NT one — so the "Sentinel" tell shows up on FOUR layers. The earlier convention (2026-07-05, sentinel-namespace-and-naming) that dropped the class prefix is DEPRECATED; its reasoning (the folder supplies context) held only for the picker, but the on-chart display Name still read generic and that's what a novice sees. The ratified law re-adds the prefix on the class and puts "Sentinel" in the display Name.

Layer Rule Example Serialization-locked?
① Display Name (in SetDefaults) "Sentinel <Thing> v<M>.<m>.<p>" — mandatory prefix + version (amended 2026-07-10, §9); (DEV) while in dev Name = "Sentinel Trend v1.0.0"; No — display-only; safe to change ANY time
② Namespace Indicators…Indicators.Sentinel (→ picker "Sentinel" folder). ⚠ STRATEGIES → BASE …Strategies, NOT .Strategies.Sentinel — NT's Strategy selector does NOT surface sub-namespaced strategies (verified 2026-07-07 on SentinelBridge: compiled clean, never listed); identity carried by class prefix + Name. namespace …Indicators.Sentinel / namespace …Strategies YES — bump only
③ Class + file Sentinel<Thing>_vX_Y_Z SentinelTrend_v1_0_0[.cs] YES — change at version bump only
④ Runtime cyan glass card + label remover ON (+ publish a …State seam wired to the Council — §9 item 6) ShowIndicatorLabel = false n/a

Base rules: - File = class = version suffix, all in sync. Keep name, class, Name, version-suffixed enums, and display strings ("v0.1.0") in sync. Bump the version + update the in-file changelog for any important change (history is per-file, not git). Old versions are FROZEN — never edit them. Minor bump (0.x.0) = architectural rework; patch (0.0.x) = incremental.

Namespace grouping (adopt at each tool's NEXT bump)

Suite indicators/strategies live in a sub-namespace so they cohere in code AND cluster in the indicator picker: - Indicatorsnamespace NinjaTrader.NinjaScript.Indicators.Sentinel. ⚠ Strategies STAY in the BASE NinjaTrader.NinjaScript.Strategies namespace — a sub-namespaced strategy compiles but never appears in NT's Strategy selector (verified 2026-07-07, SentinelBridge). Only indicators fold into picker sub-folders; strategies carry the "Sentinel" tell via the class prefix + display Name only. NT's codegen is namespace-aware — it emits Indicators.Sentinel.<Type> wrappers exposed by simple method name, so hosting from a strategy/indicator/MA-column still works (proven by vendor Indicators.LizardIndicators, .AlgoTrader, .AlgoAlpha). AddOns already use AddOns.Sentinel. (File folder is independent of namespace.) - The picker GROUPS BY SUB-NAMESPACE into expandable folders (VERIFIED 2026-07-05 — a "Sentinel" folder appeared; every vendor pack is likewise a folder). Root-namespace indicators sit flat at the top. The namespace clusters them and (per the law above) the class carries the Sentinel prefix, so it reads "Sentinel › SentinelTrend". Class names must stay globally UNIQUE (the version suffix ensures it → no generated-wrapper collision). - Never retroactively move a placed indicator. Namespace + class are its serialization identity; changing either drops it off saved charts/workspaces (same as a version bump). Adopt at the next version bump only; frozen old versions stay in their old namespace/name. See memory sentinel-namespace-and-naming. - GOTCHA — custom enums on a .Sentinel indicator: declare them in the class's own …Indicators.Sentinel namespace AND add using …Indicators.Sentinel; at the file top. VERIFIED 2026-07-05 on Eye (3 tries, cost real time). NT's generated host-wrapper is emitted in namespace …Indicators, references the indicator's own type QUALIFIED (Sentinel.Eye_v1_1_0) but leaves custom enum params BARE (SentinelEyeDirectionMode directionMode). KEY FACT: the generated #region shares THIS file's using directives (same compilation unit). So: - enum in .Sentinel without the using → bare name can't see the child namespace (CS0246), or binds to a frozen old version's same-named enum in base NinjaTrader.NinjaScript (CS0019/CS0266 mismatch). - enum in .Indicators → resolves, but it changed how NT qualified the class in the MA/Strategy partials (emitted Indicators.X instead of Indicators.Sentinel.XCS0234). - enum in .Sentinel + using …Indicators.Sentinel; → the region resolves the bare enum via the using, and the class qualifies correctly. This mirrors how CompressionBase's using …AddOns.Sentinel; resolves SentinelCardCorner. ✅ This is the pattern. Also keep only ONE version of the tool in the tree — two versions' same-named enums re-collide (CS0101). Another reason to archive old versions when rehoming (below). - Rehome mechanics: cp the file, strip the generated #region to EOF, change namespace+class+Name+header, add to csproj, F5 (authoritative). NT running re-appends generated regions + auto-adds the csproj entry (dedupe CS2002) and may briefly emit stale-name CS0111/CS0102/CS0246 region ghosts — strip-to-zero + F5 clears them. - SentinelCore and the services are edited in place (stable symbol other tools bind to) — internal version const only. - Every file opens with a header block: purpose, design/order/Sentinel notes, a ⚠ validate on SIM line for anything that submits live orders, then CHANGELOG.


8. Build & verify workflow

NT compiles every .cs under bin\Custom into one NinjaTrader.Custom.dll — one broken file blocks the whole compile. NT's F5 is authoritative.

↳ PREFERRED headless verify (supersedes the dotnet build recipe below): the installed cli-nt-bridge AddOn drives NT's OWN Roslyn compiler — C:\ntbv\Scripts\python.exe -m nt8bridge compile --type <Class> returns real {file,line,code} diagnostics in ~15s with none of the ghost errors below (NT must be running; first load needs one manual F5). F5 stays the final authority. See nt8-bridge-compile-loop memory.

Headless sanity-check (the older fallback; flaky; produces GHOST errors NT does not hit):

dotnet build NinjaTrader.Custom.csproj -t:Rebuild -p:UseWPF=false \
  -p:ImportWindowsDesktopTargets=false -nologo -clp:ErrorsOnly 2>&1 \
  | grep -E "error CS" | grep -viE "AlightenGEX|Energy\.cs|TrendArchitectBotV13" | sort -u

9. New-tool checklist

  1. Pick the type (§5). Apply the §7 FEDERATED NAMING LAW (all 4 layers): display Name = "Sentinel <Thing> v<M>.<m>.<p>" (+ (DEV) in dev), namespace …Indicators.Sentinel (indicators) or BASE …Strategies (strategies — sub-ns hides them from the selector), class/file Sentinel<Thing>_vX_Y_Z (lowercase v, three parts, underscores — e.g. SentinelTrend_v1_0_0, SentinelBridge_v0_2_0), cyan card + label remover. File = class = version suffix, all in sync. Header block + changelog + ⚠ SIM line if it trades.
  2. Palette fields (§3) — copy verbatim. No new colors.
  3. Header: cyan eye + wordmark + version chip. One cyan accent; green/red only for money/direction.
  4. WPF from the shared helpers (§3); on-chart card from the glass recipe (§4).
  5. Orders (if any): account-level unmanaged (§5); chart-scoped flatten; OrderEntry.Manual.
  6. Sentinel: state advisory-vs-blocking in the header; wire SentinelCore (§6). COUNCIL PROTOCOL (mandatory for any signal/regime/bias/context emitter): the tool MUST (a) publish a …State seam to SentinelCore carrying its read as INT/double/bool (never couple to an enum — publish (int)); (b) gate it behind a PublishState (or Publish…) property that DEFAULTS ON — do NOT ship it dark (the 3-voters-off-by-default miss cost us; audited & fixed 2026-07-07); (c) be wired into the Council as a VOTER (directional → AddVote), a MODULATOR (context that scales conviction), or a VETO (hard gate) — and added to the Council's card/Reasons audit. A hidden Signal plot alone is NOT enough — the Council reads seams, not plots (that was the CompressionBase gap). If it has no directional/context read, say so in the header. (Consuming a HUMAN interdiction rather than publishing a sensor read? That's the same idiom pointed the other way — publish HelmIntent, drain by instanceKey, publish HelmState back; see §6e.)
  7. CORPUS RULE (mandatory) — a sensor NEVER records; only the Recorder records. A regular signal/regime/context indicator MUST NOT write to the training corpus. Sentinel\Excursions\council\<schema>\ is owned solely by the dedicated SentinelExcursionRecorderone writer, one schema per folder. A tool that wants to characterize its OWN signal (a baseline) writes to Sentinel\Excursions\_baselines\<signal>\<schema>\, gated behind a property that DEFAULTS OFF, opt-in only — never the training path, never on by default. Corpus recording is a training-pipeline concern, not a sensor feature; a corpus that anyone might train.py over must be one-writer/one-schema or the model learns the mix. (CompressionBase shipped a default-ON CBRK logger into the shared dir — it muddied the corpus and cost a debug detour; default-OFF + _baselines\ routing, 2026-07-11. See memory corpus-hygiene-and-fill-fidelity.)
  8. Add <Compile Include> to the csproj; run the headless check (§8); then F5 in NT (authoritative).
  9. Update this doc if you introduced a new pattern. Update Docs/ROADMAP.md + memory.

Related: CONTRIBUTING.md (build rules/map) · Docs/ROADMAP.md (status) · Docs/SENTINEL-CONTRACTS.md (service seams) · memory sentinel-suite-architecture, sentinel-skin, gtrader21-panel-integration.