Files
BAT/CONTRACT.md
b.peck c82a2fa6df Phase 1 UI foundation: 32 style classes, 9 shared components, tab shell, placeholders
Dashboard shell: bundle expr binding + script transform, onStartup range init,
hand-rolled tab bar (position.display exprs), CONTRACT.md frozen pending G1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 12:16:16 -05:00

14 KiB
Raw Blame History

CONTRACT.md — PrimeBAT build contract (FROZEN after Gate G1)

Every builder/verifier agent works from this file. Do not deviate; changes require the integrator to edit this file first. Repo: /home/bpeck/git/buildathon. Project root: ignition/gateway/projects/PrimeBAT/. Reload: python3 tools/provision.py scan-projects. Lint: python3 tools/lint_project.py (must exit 0). Component prop schemas (ground truth for every prop name): /tmp/claude-1000/-home-bpeck-git-buildathon/a7f89b44-360c-4f23-ab63-503f3c25b53b/scratchpad/schemas/components/<component-id>.schema.json. Format exemplar (READ IT before writing any view.json): /home/bpeck/git/primebench/ignition/gateway/projects/PrimeBench/com.inductiveautomation.perspective/views/ForceRow/view.json.

Hard rules (contest + platform)

  1. Jython 2.7 everywhere (project scripts, transforms, event scripts): NO f-strings (% formatting only), no typing, no statistics. Scripts in view.json are inline JSON strings, tab-indented (\t), first char of every script/transform line block is a tab.
  2. Java exceptions bypass except Exception in Jython. Gateway-facing defensive code uses bare except: + sys.exc_info() (probe-verified).
  3. NO tag bindings in PrimeBAT. NO SQL / system.db.*. NO journalName= literals. Banned strings (lint-enforced): [default], BuildathonSim, Buildathon_DB, alarmsim, SELECT, f-string prefixes.
  4. All views/styles/scripts under PrimeControls/ namespaces. Single page /PrimeControls/Dashboard. sharedDocks stays {} (docked views prohibited).
  5. Every view tolerates bundle: null / empty arrays — no binding may throw on empty. Empty-window UX = PrimeControls/Components/EmptyState embed, not a broken chart.
  6. resource.json boilerplate for every view: {"scope":"G","version":1,"restricted":false,"overridable":true,"files":["view.json"],"attributes":{}} (style classes: files:["style.json"]; script modules: scope "A", attributes:{"hintScope":2}).
  7. Numbers/durations/timestamps rendered through PrimeControls.fmt helpers (consistency).

Probe-verified journal API facts (do not re-derive)

  • system.alarm.queryJournal(startDate=<ms|Date>, endDate=<ms|Date>) — epoch millis accepted directly; omit journalName (uses the single configured journal); nonexistent name raises java IllegalArgumentException (catch with bare except).
  • Result AlarmQueryResultImpl: iterable, len() works. One entry per transition; entries of one alarm instance share str(evt.getId()) (uuid).
  • str(evt.getState()) is compound: "Active, Unacknowledged", "Cleared, Acknowledged", etc. Transition kind per row: activation row state starts "Active" and row's getActiveData() non-null; clear row starts "Cleared" (getClearedData() non-null); ack row has getAckData() non-null / state contains ", Acknowledged". Per-instance: active_ms = min ts of Active rows; clear_ms = min ts of Cleared rows; ack_ms = min ts of rows with ack marker.
  • evt.get("eventTime") → java Date; .getTime() → ms. evt.get("eventType") is None (unused).
  • evt.getDisplayPath() = "" when unconfigured → fall back to parsing getSource() (prov:default:/tag:FOLDER/SUB/TAG:/alm:ALARMNAME).
  • evt.getPriority() → AlarmPriority enum: .ordinal() → 0..4, str() → name.
  • System rows: source == "evt:System Startup" etc., evt.get("isSystemEvent") True. includeSystem-style kwargs are ACCEPTED BUT IGNORED — always filter client-side.
  • Ack user field: evt.get("ackUserName") (unicode, may be u''). ackUser is None.
  • system.alarm.queryStatus(state=["ActiveUnacked","ActiveAcked"]) works; entries have getActiveData() with eventTime. system.alarm.getShelvedPaths() → list.

Session props (only these exist; NO agent may add any)

session.custom.PrimeControls:

{"query": {"startMs": 0, "endMs": 0, "rangePreset": "8h", "priorities": [], "areas": [],
           "states": [], "search": "", "refreshToken": 0},
 "ui": {"selectedTab": 0, "badActorFocus": ""}}
  • rangePreset4h|8h|24h|7d|custom. Header preset dropdown writes startMs/endMs/rangePreset.
  • Filters (priorities = int levels 04, areas = strings, states["active","cleared","acked","unacked"], search substring) are written by FilterBar; Apply button bumps refreshToken (filters do NOT auto-refetch).
  • ui.selectedTab: 0 Overview · 1 Analysis · 2 Bad Actors · 3 Journal.
  • ui.badActorFocus: source string; set by Overview top-5 click; BadActors highlights + clears.

Data layer (script package PrimeControls, resource paths ignition/script-python/PrimeControls/<mod>/code.py)

Modules: calc (pure), fmt (pure), alarms (gateway adapter). Pure modules run under CPython 3 for pytest AND Jython 2.7 (from __future__ import division, print_function).

Perspective entry points (exact signatures)

PrimeControls.alarms.getDashboardBundle(startMs, endMs, options=None)  # -> bundle dict (below)
PrimeControls.alarms.getJournalPage(startMs, endMs, filters=None, page=0, pageSize=50)
#  filters: {"priorities":[int], "areas":[str], "states":[str], "search":str}
#  -> {"rows":[{"id","time_ms","time_label","source","label","area","state","state_label",
#               "priority","priority_name","ack_user"}], "total":int, "page":int,
#      "page_size":int, "truncated":bool, "error":None|str}
PrimeControls.alarms.journalCsv(startMs, endMs, filters=None, maxRows=10000)  # -> CSV string
PrimeControls.alarms.getSourceDetail(source, startMs, endMs)
#  -> {"label","area","events":[journal-page rows],"daily":[{"t0","count"}],
#      "stats":{"count","avg_tta_ms","avg_active_ms","fleeting_count",
#               "top_ack_users":[{"user","count"}]},"error":None|str}
PrimeControls.alarms.getEventData(eventId, aroundMs, source)  # -> {"props":[{"name","value"}],"error":None|str}
PrimeControls.alarms.getShiftReportText(shiftHours=8, anchorHour=0, options=None)
#  -> {"text":str, "start_ms":int, "end_ms":int, "label":str}
PrimeControls.fmt.dur(ms) / num(v, dec=0) / pct(v, dec=0, signed=False) / clock(ms) / day_clock(ms)
PrimeControls.fmt.delta_chip(d)  # d = a kpi delta dict -> {"arrow","text","good"}

Bundle schema (getDashboardBundle return — every key ALWAYS present)

meta:      {start_ms, end_ms, prior_start_ms, now_ms, window_hours, event_count,
            activation_count, dropped_rows, truncated, correlation_mode,
            standing_mode, areas:[str], priorities_seen:[{"value":int,"label":str}],
            source_count, error:None|str, opts:{...effective thresholds...}}
kpis:      {activations, rate_per_hr, active_now, unacked_now, shelved_now, mtta_ms,
            mttr_ms, flood_pct, flood_count, chatter_count, standing_count, fleeting_count}
           # each value = {"value", "prior", "delta_pct", "dir": "up|down|flat|new|none", "good": bool|None}
health:    {grade:"A".."F"|None, score:float|None,
            subs:[{key,label,score:float|None,weight,detail}]}   # keys: rate,flood,chatter,standing,priority
insights:  [{severity:int(0 crit..3 info), icon:str, text:str, tab:str}]
rate:      {bins:[{t0,t1,count,flood:bool}], bin_ms, target_per_bin, flood_threshold, max_count}
floods:    {episodes:[{start_ms,end_ms,duration_ms,event_count,peak_bin_count,
                       top_source_label,top_source_count,top_area}], pct_time_in_flood}
priority:  {raw:[{name,level,count}], buckets:{low,medium,high,other},
            pct:{low,medium,high}, target:{low:80.0,medium:15.0,high:5.0}, sum_abs_dev}
heatmap:   {rows:[[int]*24]*7, row_labels:["Mon".."Sun"], max_count, total}
mtta_mttr: {mtta:{mean_ms,median_ms,count}, mttr:{mean_ms,median_ms,count},
            trend:[{t0,mtta_ms|None,mttr_ms|None,ack_n,clear_n}]}
pareto:    {total_activations, rows:[{rank,source,label,area,priority_name,count,pct,cum_pct}]}
top_sources: [first 5 pareto rows]
chattering: [{source,label,area,priority_name,count,per_hour,median_gap_s}]
fleeting:  {total, sources:[{source,label,area,priority_name,count,median_s}]}
standing:  {mode, count, rows:[{source,label,area,priority_name,active_ms,age_ms,age_h,unacked}]}
active_now: [{source,label,area,priority_name,active_ms,age_ms,unacked}]   # capped 200

Thresholds (surfaced in meta.opts): bins 10 min; flood >10/10min (end hysteresis 5); ISA target 6/hr; chatter >10/hr AND median gap ≤120 s (min 5); fleeting <10 s; standing >24 h; deltas flat band ±5%; caps: 50 000 events, 5000 bins, lists ≤50 rows.

Binding patterns (copy these shapes exactly)

P1 — bundle binding (Dashboard shell ONLY): custom.bundle ← expr binding

{"type": "expr",
 "config": {"expression": "{session.custom.PrimeControls.query.refreshToken} + '|' + {session.custom.PrimeControls.query.startMs} + '|' + {session.custom.PrimeControls.query.endMs}"},
 "transforms": [{"type": "script", "code": "\tq = self.session.custom.PrimeControls.query\n\treturn PrimeControls.alarms.getDashboardBundle(q.startMs, q.endMs, {'priorities': list(q.priorities), 'areas': list(q.areas), 'states': list(q.states), 'search': q.search})"}]}

P2 — embed a sub-view with bundle + tab visibility:

{"meta": {"name": "tabOverview"}, "position": {"grow": 1},
 "propConfig": {
   "props.params.bundle": {"binding": {"type": "property", "config": {"path": "view.custom.bundle"}}},
   "position.display": {"binding": {"type": "expr", "config": {"expression": "{session.custom.PrimeControls.ui.selectedTab} = 0"}}}},
 "props": {"path": "PrimeControls/Tabs/Overview", "params": {}},
 "type": "ia.display.view"}

P3 — bidirectional session binding (FilterBar widgets):

{"binding": {"bidirectional": true, "type": "property",
             "config": {"path": "session.custom.PrimeControls.query.search"}}}

P4 — event scripts: buttons/dropdowns fire events.component.onActionPerformed; labels/icons/containers fire events.dom.onClick (Designer-verified shape). Action object is identical for both: {"config": {"script": "\t..."}, "scope": "G", "type": "script"}.

"events": {"dom": {"onClick": {"config": {"script": "\tsystem.perspective.openPopup('PC_HealthScore', 'PrimeControls/Popups/HealthScore', params = {'health': self.view.params.health}, showCloseIcon = True, draggable = True)"}, "scope": "G", "type": "script"}}}

P5 — flex repeater from bundle slice:

{"type": "ia.display.flex-repeater",
 "propConfig": {"props.instances": {"binding": {"type": "property", "config": {"path": "view.params.bundle.top_sources"}}}},
 "props": {"path": "PrimeControls/Components/SourceRow", "direction": "column"}}

Repeater instance keys become the child view's params → bundle array element keys == component view param names (snake_case).

View param declaration: "params": {"bundle": null} + propConfig "params.bundle": {"paramDirection": "input", "persistent": true}.

Popups (IDs are constants)

ID View Params
PC_HealthScore PrimeControls/Popups/HealthScore {health: bundle.health}
PC_AlarmDetail PrimeControls/Popups/AlarmDetail {source, label}
PC_EventDetail PrimeControls/Popups/EventDetail {event: <journal row dict>}
PC_ShiftReport PrimeControls/Popups/ShiftReport {}

Close: system.perspective.closePopup('<ID>').

Style classes (foundation-owned; reference as "style": {"classes": "PrimeControls/Card"})

PrimeControls/Priority/{Diagnostic,Low,Medium,High,Critical} (solid chip bg+fg) · PrimeControls/PrioritySoft/{...same 5} (tinted bg) · PrimeControls/Grade/{A,B,C,D,F,NA} · PrimeControls/Card · PrimeControls/CardTitle · PrimeControls/Toolbar · PrimeControls/PageBg · PrimeControls/Chip/{Good,Bad,Flat} · PrimeControls/Tab/{Active,Inactive} · PrimeControls/State/{Active,Acked,Cleared} · PrimeControls/Text/{Big,Kpi,Muted} · PrimeControls/Empty. Priority scale: Diagnostic #8A94A6 · Low #5B9BD5 · Medium #E5C453 · High #E8883A · Critical #D64550. Accent: #3B7DD8. Agents may ADD classes only under style-classes/PrimeControls/<TheirArea>/..., never modify shared ones.

Shared components (foundation-owned, under views/PrimeControls/Components/)

View Params (all input)
Components/TabButton title:str, index:int (writes ui.selectedTab onClick; active style expr)
Components/KpiCard `label:str, value:str, unit:str, delta:{...kpi delta}
Components/PriorityCard priority_name:str, count:int, pct:float
Components/InsightRow severity:int, icon:str, text:str, tab:str
Components/SourceRow rank:int, source:str, label:str, count:int, pct:float (click → focus+tab 2)
Components/HeatmapCell count:int, max:int (bg alpha-scaled accent)
Components/ScoreBar `key:str, label:str, score:float
Components/EmptyState message:str, icon:str
Components/HealthGauge `health:{grade,score,subs}

View ownership (Phase 2/3)

A: Dashboard/Header + Dashboard/FilterBar · B: Tabs/Overview · C: Tabs/Analysis · D: Tabs/BadActors · E: Tabs/Journal · F: Popups/HealthScore+Popups/ShiftReport · G: Popups/AlarmDetail+Popups/EventDetail · H: Components/HealthGauge upgrade. Integrator-only: Dashboard/view.json, session-props, page-config, shared Components, root style classes, script-python/*, SimHarness.

Verify loop (every agent, before declaring done)

python3 tools/lint_project.py
python3 tools/provision.py scan-projects
sleep 5; docker logs buildathon-ignition --since 1m 2>&1 | grep -iE "error|exception|traceback"
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8088/data/perspective/client/PrimeBAT

Schema uncertainty → read the harvested schema file; still unsure → minimal probe view in SimHarness/views/Scratch/Current (coordinate via integrator), never guess deep chart JSON.