Every version, one line each
Newest first. Check the running version at the console header, or
/api/version. VERSION.txt is bumped on every shipped change, so a gap
between two dates in this list is simply a period with no release, not a missing entry.
From 2026.08.1-1 the version scheme is 2026.08.x-y: x names the phase of the 2026.08
series (FS 2026.08, decision D4), y the release within that phase. The date based
scheme ended at 2026.07.20-93, and update.log records every transition as old string
to new string, so the audit line stays continuous across the change.
| Version | What it contained |
|---|---|
| 2026.08.9-8 | No update has ever replaced web\index.html, on any installation, since packages became reproducible (update.ps1). Two reasonable decisions combined into one silent failure. tools\build_package.py stamps every entry in the zip with a fixed date so that building the same source twice produces the same bytes, which means each release extracts with an identical last write time to the release before it. robocopy decides whether a file needs copying from its last write time and its size. And index.html differs between releases only in eight ?v=<version> query strings, every version string being the same length, so the file is exactly 163013 bytes at 2026.08.9-5 and exactly 163013 bytes at 2026.08.9-7. Same time, same size, so robocopy classified it as identical and skipped it, every single time, reporting success. VERSION.txt escaped because the root files are copied with Copy-Item -Force, which never compares anything. So the server advanced and the markup did not, which produced a console reporting an old version against a new server and, worse, a browser that kept requesting the assets under their old ?v= and being served its own cache while the new app.js and style.css sat unread on disk. That is exactly the stale-asset failure the ?v= scheme was built to prevent, arriving through the one door nobody had checked. It also means any file whose size happened not to change across a release was never updated either, on any install, which is why a full forced mirror is worth running once. Every mirror now passes /IS, robocopy's "include same files", so the comparison cannot be fooled by a timestamp. Removing that flag will silently break updates again and the code says so. Stage 5 then proves its own work rather than trusting it: it reads VERSION.txt, reads every ?v= stamp out of the installed index.html, and throws with both values named if they disagree. Nothing compared those two before, which is the only reason this survived forty six updates. Also fixed, the red block that made a healthy update look like a crash: the pip call no longer redirects stderr with 2>&1, because PowerShell 5.1 wraps every redirected stderr line in an ErrorRecord and a routine "a new release of pip is available" notice therefore printed a NativeCommandError that reads like a stack trace. stderr is captured for us already, and --disable-pip-version-check removes the notice at source. The ErrorActionPreference restore moved into a finally, since the old one sat on the success path only, so a throw inside that block left Continue in force for the rest of the run and quietly disarmed the restart in stage 7. |
| 2026.08.9-7 | The Trends page opened with most of a screen of blank space between the period controls and the tab strip, and the fix is one declaration (web\static\style.css, web\static\app.js). The rail and the main column are a two column grid, and the rail is meant to span every row of that column, which it was asked to do with "grid-row: 1 / -1". That does not work, and it fails quietly. A negative grid line counts back from the end of the EXPLICIT grid, this grid declared no rows at all, so -1 resolved to the first line and the rail occupied row one on its own. Row one then grew to the rail's height, and on Trends the rail is a tree of every point in the project capped at the viewport, so the tab strip sitting in row two was pushed almost a full screen down with nothing in the space above it. Declaring the three rows the main column actually has, the period toolbar, the tab strip, and whichever analysis pane is open, is what makes -1 mean what it says. Spanning some large number instead would not have fixed it: an over-span creates that many empty tracks and a row gap between every one of them, which is the same blank space arriving by a different route. The narrow layout resets to implicit rows, because with one column there is nothing to span and three declared rows would fight the stacked order. Also in this release, the stale-assets banner stops blaming the wrong thing. It told you to hard refresh and then, if that failed, to suspect a caching proxy. Both are the unlikely causes: index.html is served no-store by the cache_ui middleware for exactly this reason, so the browser always learns the current version, and a console reached on 127.0.0.1 has no proxy in front of it. The likely cause is a partial update in which VERSION.txt was replaced and the web folder was not, and no amount of refreshing can repair a stale file sitting on the server. The banner now says that first and points at update.bat, having sent one person hunting through browser caches for twenty minutes at three in the morning. Both changes are desk verified rather than measured: layout is the one thing the shipping gate cannot see, because jsdom parses the page but never lays it out. |
| 2026.08.9-6 | Every point on every bus read "--", the point list and the graphics were blank, no trend would draw including trends of data already stored, and the cause was two SQL statements in the wrong string literal (app\db.py, app\main.py, app\mqtt_client.py, new tools\diagnose_live.py, new tools\diagnose_ingest.py). The previous release added two indexes to speed up the points listing and wrote them into HISTORY_SCHEMA rather than SCHEMA. HISTORY_SCHEMA runs against every project history file, and a history file holds samples and nothing else, so a statement naming the points table has nothing to bind to. CREATE INDEX IF NOT EXISTS does not save you here: the guard covers the index, not the table, so the statement raises "no such table: main.points" instead of skipping. That raise came out of _conn_for, which every read and every write of history passes through, so history stopped being written and the eleven days of samples already on disk stopped being readable at the same instant. The live cache went with it, because _emit wrote history first and cached second, on one unguarded line, so a historian fault took the whole plant off the screen. Alarms carried on throughout, which is what made it look like a driver problem: the alarm worker is a separate process with its own broker subscription and never calls the historian. Three things are fixed, and only the first is the bug. The indexes now live in SCHEMA next to the table they index, where they are also created for the first time, so the speed-up they were written for is finally delivered. _emit caches and broadcasts before it writes history, and writes it inside its own guard, so from here a historian fault costs history and nothing else. And the bare "except Exception: pass" that swallowed this for three hours now counts what it catches and names it: /api/status gained historian_errors, historian_last_error, route_errors, route_last_error and dropped_unroutable, the last of which counts value messages discarded on two paths that previously incremented nothing at all. The diagnosis took a night because every instrument said the system was healthy. The bridge log reported 4989 values published and zero errors sixty seconds before the export that showed 5701 empty points, because the bridge counts publish calls and never inspects the result. /api/status reported no errors because nothing counted the ones being thrown. The lesson is the one this project keeps paying for: a component reporting success is not the same fact as the thing having happened, and a handler that discards an exception silently is not resilient, it is just quiet. Two read-only diagnostic tools ship with this release so the next hour like this one is a minute: diagnose_live.py checks the registry, the historian join and every project history file against the point ids actually stored in it, and diagnose_ingest.py walks the swallowed path with nothing caught and prints the traceback. Three hours of samples between the failure and the restart were published and alarmed on but never stored, and are gone; everything before it is intact and readable again. Also in this release, the riza1 naming convention vocabulary was translated into all eight non-English phrase memories, 335 entries each covering the systems, equipment types, points, components, meter scopes and floor tokens of the CTO owned register. Nothing on screen changes yet, honestly stated: those labels are produced in Python and carry no translation key, so the keyed bundles the browser fetches are byte for byte identical. The translations are in place for the release that wires them up, and four terms are deliberately left in English because the register glosses them and vocab.py still records them as awaiting confirmation. |
| 2026.08.9-5 | The language picker shows a flag beside every language, and to do that it stopped being a dropdown (web\index.html, web\static\style.css, web\static\app.js, new tests\test_locale_ui.py). The obvious way to add flags is an emoji in the option, and on this product it is the wrong way: Windows ships no flag glyphs, so Chrome and Edge render an emoji flag as a boxed two letter code, and Windows is where the console runs. A native select cannot show an image in an option either, so the control is now a button and a listbox, and the nine flags are SVGs drawn and inlined here rather than fetched, because the console ships offline and vendors everything it uses. All nine cost 10 KB of stylesheet. Replacing a native control means giving back by hand everything it used to provide for free, so the button reports whether the list is open, the list and its options carry the listbox and option roles and the selected state, and the keyboard moves with the arrows, chooses with Enter or Space, and closes with Escape or Tab. The flag sits beside the native name and never replaces it: a flag is not a language, somebody hunting for their own language reads the word they call it by, and English is not spoken in one country. The new suite exists because nothing about a missing flag is loud. Three files now have to agree, the locale register in Python, the flag map in the JavaScript and the image rules in the stylesheet, and a class that does not match its rule renders an empty box exactly the size of a flag, in a control most people touch once. It would have sat there for releases. |
| 2026.08.9-4 | The console gets faster, and it starts saying what things are instead of what they are called on the wire (new app\vocab.py, app\main.py, app\db.py, web\index.html, web\static\style.css, web\static\app.js, tools\check_i18n.py, new tests\test_vocab.py). Speed first, because it was measured rather than felt. Every page load was fetching 1.16 MB of JavaScript and CSS again, because a middleware set no-store on all of it while the ?v= stamps on those same eight URLs exist for the sole purpose of making them cacheable; the two had been contradicting each other since the versioning landed. Versioned assets now cache for a year and the markup never caches, so a new release is a different URL and the stale app.js failure the blanket rule guarded against is prevented at the URL instead of at the header. Responses are compressed, which takes a first load from 1.16 MB to 312 KB and does better than that on a points listing. SQLite was working within its default page cache of about two megabytes on a machine bought to run nothing else, and now takes 256 MB with memory mapped reads and in memory temporary tables. The points listing, the single hottest query in the product, had no index at all and was scanning the whole table and then sorting it; it has two now. Naming second. The riza1 topic is four segments of three and four character codes, which is right for a wire and wrong for a person: a technician reading cld/ct-01/sts on a phone at two in the morning should not have to remember that cld is the chilled water system and ct is a cooling tower. The platform now reads the CTO owned Data Point Naming Convention v2.1 and renders those segments as words whenever technical mode is off, across the points table and the tree. It is a reading of that register and never an extension of it: a code the register does not define comes back unchanged, so a site with its own vocabulary keeps exactly what it has today and nothing is invented to fill the gap. Turning technical mode on now also rebuilds the rows, which it did not, so the button changed while the screen did not and the setting looked broken. Technical mode itself reads as a state rather than a verb, grey and OFF or green and ON, because the off state used to be the absence of a word and an operator asked to confirm it was off had to know that no suffix meant off. The session line moved to a row of its own at the top: it was the third item of one wrapping row, so whether it sat beside the tabs or dropped below them depended on how long the signed in name and the version string happened to be, and two people on the same build saw two different headers. Last, a hole in the translation gate. check_i18n only ever read the markup, so 45 strings the JavaScript builds at runtime, including the whole Modbus discovery interface shipped in 2026.08.9-1, were in no catalogue at all and rendered English inside every other language while every locale reported complete. The gate reads app.js too now, all 45 are registered, and the locales report the shortfall honestly instead of claiming completeness. |
| 2026.08.9-3 | The one line an operator ever sees when a Modbus bridge cannot start now tells them the truth, and tells it about the right interpreter (bridge\modbus_bridge.py). It said pymodbus rides as an optional dependency and to run pip install pymodbus. Both halves had gone stale. pymodbus has shipped with every installation since 2026.08.8-39, so on a current build the interesting question is not whether somebody forgot to install it but why the update did not land it, and the usual answer is that the dependency step could not reach pypi.org through a proxy or a VPN; update.ps1 reports a failed pip as a warning and carries on, which is precisely how an installation arrives at this message looking fully updated. Worse, a person who follows the old advice literally opens a shell and installs into whatever Python is first on PATH, while the platform spawns the bridge with sys.executable, which is the installation own virtual environment, so the package lands somewhere the bridge cannot see and the same message comes back a second time. That is the failure the Windows rules open with, and repeating the advice that causes it is worse than saying nothing at all. The message now prints the absolute path of the interpreter that is actually missing the package, names the likely cause, and gives the exact command quoted against that same path. Nothing else changed in this release: it is one message, and it is the only surface that ever reports the condition, because no endpoint reports installed dependencies. |
| 2026.08.9-2 | Two field reports, one root cause each, both of them a wrong assumption rather than a missing feature (app\telegram_commands.py, app\telegram_ask.py, app\plant_ask.py, app\ai_analysis.py, new tests\test_alarm_codes.py). The first arrived in a shape that was hard to credit: an operator reads an alarm id off a push alert, replies Explain with that id, and is told the id is not a live alarm code; asks what is active, and the same alarm is listed straight back. Both answers were true at once. Alarm codes are swept when their alarm is no longer live, and the sweep asked the last retained alarm snapshot what live meant. The alarm worker publishes a transition the instant it sees one but republishes that snapshot only every thirty seconds, so at the moment any alert is built the snapshot can be half a minute behind the world, and the sweep read that gap as proof the alarms were gone. Two alarms raising inside one window, which is the normal shape of a plant trip rather than an edge case, therefore cost the first one the code its alert had already delivered. With no snapshot at all, which is a platform that has just restarted or the first alarm of an incident, the placeholder collapsed to NOT IN of an empty string, which is true of every key, and the project lost every code at once. The sweep stays, but it no longer treats a snapshot as current: the alarm being minted for is always kept, since the caller holds proof it just raised, and nothing is swept until it has outlived that staleness by six hours. A code that outlives its alarm is harmless, because it resolves and the action then reports honestly that the alarm is not active; a code deleted while its alert is still on a phone is not. The second report was that the AI would gather the data and then fail to answer, on Telegram and in the console alike. The budgets were written for a model that did not think. On the shipped default model a request that omits the thinking field runs adaptive thinking, where on the older models omitting it meant no thinking at all, and max_tokens is a ceiling on thinking plus answer text together rather than on the answer alone. So the tools ran, the rows came back, the last round spent the entire budget reasoning, and the caller reported an empty answer. One call path had already been fixed this way in isolation and carried a comment saying so; the other five had not. The per round budget for analysis rises to twelve thousand tokens and the one shot narrations to four thousand, including the Telegram loop, which had nine hundred. This is a ceiling and not a spend: raising it costs nothing on a round that was already answering, and is the difference between an answer and silence on a round that was not. Thinking is deliberately left on for the tool loops, because it is what makes them pick the right tool and the right alarm key. Where an answer still comes back empty the reply now names the stop reason and the block shapes that did arrive, so the next occurrence is diagnosed from evidence rather than from theory. Two smaller honesty fixes travel with them: a Telegram user connected to no project at all was told their account spanned too many, which sent them to an admin asking for the opposite of what they needed, and the refusal for a user who really does cover several buildings now names them. Trends also changes shape on the same release (web\index.html, web\static\style.css, web\static\app.js, tools\check_dom.py, the nine locale bundles). The period controls scope every view on that page, but only six of the seven views were tabs: the trend itself sat permanently above the strip, so the one view that was not a tab looked like the page and the six that were looked like an appendix to it, with the switch stranded below a graphic. The trend is now a tab like the rest, first in the strip and open by default, and the strip sits directly under the period controls, so the thing you switch reads against the thing that scopes it. Two consequences were handled rather than discovered later: a chart drawn while another tab is open measures its canvas as zero, so entering the trend tab re-measures it, and the print report no longer breaks the page after a block whose only remaining content is a toolbar, which would have produced a sheet carrying nothing but the report title. The rule that the strip precedes what it switches is now the seventh rule in check_dom.py rather than a habit, because a tab strip below its own content reads as belonging to whatever sits above it. One late catch is worth recording because it nearly undid the rest: the startup hook passed its own copy of the answer budget, so on any installation whose config.json simply does not mention ai.max_tokens, which is most of them, the raised default was written straight back down to the old one before the first question was ever asked. The override path and the default path each looked correct on its own and disagreed only when read together. Both startup hooks now take their fallback from the module rather than repeating a number, the floor under a configured budget is the one shot answer size rather than the old thousand, and a test asserts that neither hook carries a literal again. |
| 2026.08.9-1 | Modbus discovery is rebuilt around the wall between evidence and configuration, and the whole console speaks nine languages (new bridge\modbus_evidence.py, new app\unified_export.py, new app\i18n.py, new app\labels.py, new web\static\i18n\ with nine locale bundles and their phrase memories, new tools\i18n_extract.py, tools\i18n_build.py, tools\check_i18n.py, tools\i18n_smoke.mjs, app\main.py, app\db.py schema 39 and 40, bridge\modbus_bridge.py, bridge\modbus_scan.py, bridge\modbus_map.py, web\index.html, web\static\app.js, new tests\test_mbs_revc.py and tests\test_i18n_hmi.py). Two functional specifications landed together, and the Modbus one is a correction rather than a feature. The scanner was already right: bounded blocks, exception driven subdivision, honest partial verdicts, all shipped in 2026.08.2-2 and unchanged here. What was wrong was the last step. A survey ended at a button that downloaded a draft beez_modbus_map, and a JSON file a bridge can read looks exactly like configuration to whoever receives it, however carefully the page around it was worded. MBS-FS-2026.08 Rev C says a discovery artifact is not configuration, and that sentence is now a set of exceptions rather than a paragraph. A survey opens a session that records its own scope, policy, actor and budget before a single packet is sent. Everything the scan observes becomes immutable evidence, written INSERT OR IGNORE against a content derived reference, so replaying a scan cannot rewrite what the first one saw and there is deliberately no UPDATE path in any endpoint. Every discovered register becomes a candidate carrying every surviving interpretation with its confidence, and approving one whose datasheet disagrees with the wire now requires a written rationale. The only operational artifact the platform will emit is a unified bundle, schema 3, built from approved candidates and nothing else, which the test suite proves by handing it straight back to /api/import-bundle. A legacy map is migration input: it transforms into the unified schema and reports by name every field that had no destination there, and it still needs approval. The evidence report is a separate download that says NON-OPERATIONAL on its face. Three smaller honesty fixes travel with it: the coverage model now distinguishes addresses a device denied from addresses the scan never reached, which used to read identically as nothing found; a Unit ID answering behind a gateway is classified as a routing candidate rather than a device, because a gateway answers on the device's behalf and cannot prove one exists; and the sweep default is the protocol's full 1 to 247 rather than a convenience window, with Unit ID 0 never swept since an answer at the broadcast address identifies nothing. Two real defects surfaced while proving it and are fixed: db.execute returns lastrowid, so an ignored INSERT OR IGNORE still reported a row written, and a new db.execute_count answers the question callers were actually asking; and a discovery session recorded an empty actor, because a session token carries the user id and not the username. The multilingual work is the second specification and rests on one invariant, that a label is presentation and never identity. All 840 customer facing strings in the console now resolve through translation keys, generated mechanically by tools\i18n_extract.py so the coverage is complete rather than curated, and answered from 687 distinct phrases per locale so that Refresh reads the same way in all ten places it appears. Simplified Chinese, Japanese, Korean, Thai, Bahasa Indonesia, Hindi, French and German ship complete and are marked review_status unreviewed, which an administrator can see: they were produced with the product, not by a native speaking reviewer, and replacing a phrase file with a reviewed one is a data change with no code behind it. Nothing here touches identity. Locale is a new column beside the user, labels are new rows keyed by the canonical reference that already existed, and no existing column changes meaning, which is why switching language cannot move a point, a relation endpoint, a permission or a pending write. Technical mode shows protocol addresses to the roles the platform already trusts, is checked at read time so a demotion takes effect immediately rather than at next login, and is audited. Everything runs from files that ship inside the package, so a site with no internet loses nothing. tools\check_i18n.py joins the shipping gate and CI, catching a keyed string with no catalogue entry, a catalogue entry no longer in the markup, and a placeholder set that drifted between locales. |
| 2026.08.8-39 | A Modbus TCP project can finally be pointed at its device from the console, the connect form follows the bus its bridge speaks, and pymodbus stops being a manual step (app\main.py, app\bus_registry.py, web\index.html, web\static\app.js, requirements.txt, docs\MODBUS.md, new tests\test_bus_connect_form.py). Three things were wrong and they were the same thing. The Connect a project to a bridge form posted to the bacnet endpoint whatever bus the chosen bridge spoke, and the server checked only that the bridge existed, never what it was; connecting a project to a modbus-tcp bridge therefore wrote the bacnet_target and bacnet_ede columns, which no Modbus driver reads, since modbus sources come from bus_sources. The bridge then started, found no source of its own bus, polled nothing and reported no error at all, which is the same shape of defect as the batch writes FAT 3.0 found: a confident success where no check was made. Meanwhile the endpoint that does the job properly, PUT /api/projects/{id}/modbus, has existed since 2026.08.2-3 and carried a target field all along, and no console page had ever called it, so the IP address of a Modbus device could only arrive inside a bundle. The form is now bus aware: it reads the bus from the bridge it is given, the bridge list says which bus each one speaks, and each bus gets its own fields and its own endpoint. Modbus asks for a target as host:port, completes a bare address with 502, and offers neither local direct mode nor the Who-Is scan, because both are BACnet ideas with no Modbus meaning and a form that asks a question its endpoint cannot answer is worse than one that does not ask. REST asks for no target at all, since its URLs live inside its own map. The bacnet endpoint now refuses a bridge that does not speak BACnet and names the endpoint to use instead, so the silent no-op cannot happen through the API either, and the modbus endpoint restarts the affected bridges exactly as its BACnet sibling always has, because a saved change that waits for a hand restart looks like a change that did not take. The target rule lives once, in the registry, and splits the string the same way the driver splits its own source argument, so a target the console accepts can never be one the bridge reads differently. On the deliberate design question behind all of this: the device IP belongs to the project, not to the bridge, which is what lets one Modbus bridge serve several buildings, each with its own device, exactly as one BACnet bridge already serves several sites. Separately, pymodbus now ships with every installation instead of riding as an optional dependency on the rdflib pattern. Installing it by hand was one step nobody remembers at the moment a bridge refuses to start, and the driver could only say so after the fact; it is small, pure Python and inert on a site that runs no Modbus bridge. The driver keeps its one line refusal, because an installation can always be older than this release, and the documentation now says plainly that it must go into the installation's own virtual environment, since the platform spawns bridges with sys.executable and a package installed against any other interpreter is invisible to them. The new suite proves the pure half: the target normalisation with every refusal by its message, and the structural agreement between the console's bus table, the endpoints the server publishes and the drivers the registry has registered, three lists that used to be one list written three times, so a driver registered without a console entry now fails the ship instead of silently falling back to BACnet. The wire half, saving a Modbus connection through the live console, the 400 on a mismatched bus and the restart that follows, needs a running server and is desk verified here, listed for the field run. |
| 2026.08.8-38 | One width for the whole console, and the layout numbers written down once instead of being retyped per page (web\static\style.css, web\index.html, web\help.html, tools\check_dom.py, tests\test_check_dom.py). Every page already shared a content column, but almost nothing else did. The top bar spanned the window while the content sat in a narrower centred column, so the brand mark and the Sign out button lined up with nothing on the page below them, which on a wide monitor is the thing that reads as untidy even when every page is in fact the same width. The bar still spans the window, because a header that stops short of the edges looks like a mistake, but its contents now sit in exactly the content column, so the brand begins where the content begins. The navigation rail was 320px on Trends and 272px on Points, Graphics, Maintenance and Dashboards, and those five pages were built three different ways, two grids and a flex row, each with its own collapse breakpoint. They are now one definition with one rail width and one breakpoint, and the numbers behind all of it (the column, its gutter, the rail, the gap) are four custom properties at the top of the stylesheet rather than values retyped in nine places. The column itself widened from 1280 to 1600. This is a supervision console rather than a magazine: the control loops table carries fourteen columns and had about 89 pixels for each of them, so every header wrapped, and on a 1920 monitor a third of the screen was margin. The help text still reads comfortably because it sits inside cards, not across the full column. Ten tables wide enough to squeeze (the loops, the audit trail, the bridges, the sites, the tasks, the bus traffic, the imported setpoints, the boost definitions, the PV candidates and the dynamic setpoints) now scroll inside their own card instead, so a wide table never makes the whole page scroll sideways. Two smaller repairs found while reading: the chart card was declared twice with different padding and the second quietly won, and the print stylesheet styled an app footer that does not exist. Converting the rail pages from flex to grid also woke a rule that had been asleep: card.wide sets a grid column span meant for the admin grid, which was inert inside a flex row and would have made the maintenance equipment card span the tree's column the moment those rows became grids. It is now scoped to the admin grid where it belongs, the one card carrying the marker by accident no longer does, and the structure linter fails the ship if that combination ever appears again. Finally the two documentation pages, the quick help and the guide, disagreed with each other at 960 and 880 pixels; a reader moves between them, so they now match. The guest room page stays deliberately narrow, because it is a phone page for one room and nothing else. |
| 2026.08.8-37 | One stray closing tag that had been quietly breaking the console, the missing way to create a bridge on any bus but BACnet, and the long pages split into subpages (web\index.html, web\static\app.js, web\static\style.css, new tools\check_dom.py and tests\test_check_dom.py). The defect first: a single extra </div> inside the relations subpage closed the wrapper that holds the whole application about four hundred lines early. Every browser repairs that the same standard way and none of them complains, so nothing looked wrong in the file, but the repair moved four Admin subpages (Audit, Update, AI and the Telegram bot) and two entire panels (Control and Dashboards) up to be children of the document body. The tab switcher hides panels, and those sections were no longer inside one, so whichever of those four Admin subpages had been opened last stayed on screen at the bottom of every other page for the rest of the session, and Control and Dashboards sat over the login form after a logout. Nothing in the shipping gate could see it, because the file parses, the JavaScript parses and every id still resolves; the fix is one tag, and the durable part is a new structure linter that reads the page the way a browser does and fails the ship on a stray or unclosed tag, on a panel that is not inside the application view, on a subpage pane that has no button or a button that has no pane, and on a duplicate id. It is proven by feeding it the exact defect that shipped, alongside seven other cases, in its own suite. Second, the bridge editor never had a bus field, so every bridge the console created was a BACnet bridge by default and a Modbus TCP one could not be made at all: the Modbus survey page listed the modbus-tcp bridges, found none, and there was no screen anywhere that could produce one. The editor now asks for the bus, offering exactly the drivers the server has registered rather than a list kept by hand in the console, and the BACnet-only settings (local IP, transport, UDP port) appear only for BACnet, because a Modbus TCP or REST bridge connects outward and binds no local port. The bridge table says "none" in the port column for those buses instead of showing a number they do not hold, and the survey page, when it finds no bridge, now says where to make one. Third, the long pages. Alarms was six cards down one scroll and is now Live, History, Log and Rules; Trends keeps its point tree and chart and puts its six analyses (time in state, distribution, distance from setpoint, scatter, carpet, flow) behind tabs; Control separates its loops from the strategy wizard, dynamic setpoints, boost and proposed actions. A subpage loads its data when it is opened rather than when its page opens, which is not only thrift: Chart.js measures a canvas as it builds the chart, so the alarm history charts had to move to their own subpage's loader or they would have been built with no size at all. Print report is deliberately unaffected, since a report is meant to carry every analysis and not only the tab that happened to be open, so the print stylesheet reveals all of them. Under all of that, one switcher now serves every tabbed page. Admin, Bus Systems and Maintenance each had their own copy with the pane names written out by hand, which is exactly what shipped the Telegram bot tab invisible in 8-21; the shared one reads the panes from the page itself, and the new linter fails the ship if a button, a pane and a loader ever stop agreeing. |
| 2026.08.8-36 | The eight defects FAT 3.0 found on the lancaster bench against a live BACnet network, corrected (bridge\bacnet_bridge.py, app\main.py, app\telegram_ask.py, web\static\app.js, web\index.html, tests\bacnet_cov_srv.py and four suites; the pure halves are unit proven here, the wire halves need a stack and are listed for the field run). Two of them were the same failure wearing different clothes, and they are the reason this release exists: the platform reported writes as landed that the controller had never accepted. On a single property write, the whole verification block was wrapped in float(token), and since the value parser normalises every binary and enumerated input to the WORD active or inactive rather than to 1 or 0, that conversion raised on every one of them, the exception was swallowed, and control fell through to an unconditional success. The consequence was not a corner case: no write to any binary or enumerated object was ever verified, on any site, for the life of that code. The same operator typing 0 got a correct refusal and typing inactive got a false success, and since the property grid displays the word forms, the word form is what an operator actually types. Verification now compares on the normalised token in both domains, numeric where both sides are numbers and by name where they are not, so active, 1 and true are one another and so are inactive, 0 and false; a read-back that differs and equals the value from before the write is a refusal, and a read-back that differs otherwise is reported as written with a note saying what the device actually holds. The rule behind the change is the one worth remembering: never return a bare success on a comparison that could not be made. The batch priority write was worse, because it performed no verification at all. A row counted as written whenever the call did not raise, which against a controller that accepted nothing produced the sentence 2 written, 0 failed: confident, specific and wrong. Batch write is the bulk tool, the one an engineer uses to push a hundred setpoints at handover and precisely the operation where nobody checks each row by hand afterwards, so on an outcome-based contract a batch believed applied but never written is a measurable performance loss that nobody would think to look for. Every row is now read before and after its write and judged on the same rules as the single write, and the result carries five counts rather than three, because written, refused (the device ignored it), unverified (the write went out but the read-back could not be taken), skipped and failed are five distinguishable facts and folding any pair together would state something the platform does not know. The cost is one extra read per row on a bus where a read took under twenty milliseconds, against rows already spaced by the batch pacing gap. Three smaller repairs of the same family. The audit trail now covers bundle import and the whole bridge lifecycle, which it did not: three imports and a bridge created, started, re-ported, restarted and stopped left no trace at all while the project deletion beside them was recorded, and since onboarding a building and standing up a field bridge are the two most consequential things an administrator does here, the Product Data Sheet's claim of a row for every state changing operation is now true rather than aspirational. The property grid no longer shows Python object reprs where the priority array should be: the priority ladder IS the thing an engineer must read before writing to a commandable object, because it says who already owns the point and at what level, and it used to render as sixteen memory addresses, so the bacpypes3 choice and optional wrappers are unwrapped to their set member and an empty slot reads as null. Device refusals are reported in the device's own words instead of a Python exception repr. Two honesty fixes that cost nothing and matter anyway. Refused writes used to end with the sentence the bridge log carries the stack's reply, and it did not: an engineer sent to the log found the attempt and the word FAILED and nothing else. Each refusal path now writes what the platform actually knows, and the sentence describes what the reader will genuinely find. The Ask on Telegram card used to keep the last error it had ever seen, with nothing clearing it on a later success, so an operator who had just repaired their certificate trust still read the certificate failure beside a reader that was demonstrably working, and concluded the repair had failed; a successful poll now clears it, and an error that is still shown carries its age. Finally, the batch write confirmation moved from a browser prompt() to an inline typed field, because modals are suppressed in some embedded and automation contexts and a suppressed confirmation is not a weaker guard but no guard at all. The soft BACnet server in tests gained a writable binary value, without which the binary fix could not be regression tested at all: the only binary object on the bench sits on a controller that refuses every write, and a refusing device can prove the refusal path and nothing else. |
| 2026.08.8-35 | The rebuilt installer's own two defects, found on the machine it was written for, and a linter so that class of defect cannot ship again (installer\install-windows.ps1, update.ps1, rollback.ps1, new tools\check_powershell.py; every fix is receipted in the container, and the linter is proven by feeding it the exact line that failed). The 8-34 installer stopped at stage 1 with nothing but a fragment of a Python traceback, and the cause was two behaviours of the Windows PowerShell 5.1 that actually ships with Windows, neither of which exists in the PowerShell 7 the development container used to verify the script. First, 5.1 does not escape embedded double quotes when it builds a native command line, so the version probe python -c import sys; print(".".join(...)) arrived at python.exe as print(..join(...)), whose SyntaxError begins with the line File "<string>", line 1, which is exactly and only what the field log contained. Second, under ErrorActionPreference Stop, anything a native command writes to stderr becomes a TERMINATING error once its streams are redirected, and this particular probe, unlike its siblings, sat outside a try block, so a chatty program ended the installation. The same mangling was waiting one stage later, where the dependency verification printed a quoted token that would have arrived unquoted and failed on a perfectly healthy machine. The fix removes the class rather than the instance: every native call now goes through one helper that manages the error preference and returns an exit code with the captured output, and every Python probe is written without a single double quote, carrying its answer in an exit code rather than a printed string, with the version read from --version, which needs no argument payload at all. Running the linter across the whole repository then found the identical stderr trap in the update and rollback scripts, where the dependency step is wrapped in a try block: not fatal there, but pip writes deprecation and resolver notices to stderr as a matter of routine, so those updates silently skipped installing dependencies whenever it did, and reported a warning as though something had genuinely failed. Both are fixed the same way. The linter itself is the durable part, since a parser cannot see any of this: it understands which error preference is actually in force at each line and whether a call sits inside a try block, so it separates a hazard that kills a script from one that merely degrades it, and it is part of the shipping checks from now on alongside the package verifier. |
| 2026.08.8-34 | The installer rebuilt in six stages, and the real cause of the failed field installation found (install-everything.bat, installer\install-windows.ps1, tools\verify_package.py, the installation guide; the PowerShell is desk verified, now with a real parser rather than by eye, and the fix's core premise is runtime receipted in the development container). The 8-33 diagnosis was wrong and is corrected here: the machine had Python 3.12 all along, so the version gate raised in that release, while correct in itself, was never the cause. The cause was that one installation used three different Python interpreters. Console mode (run.bat) has always created a virtual environment in .venv and installed the dependencies into it; install-everything.bat installed the packages with whichever of "py" or "python" it found first; and this installer registered the SERVICE against a third choice, the first interpreter on PATH satisfying the version check. A Windows service runs as LocalSystem, so the moment pip had put the packages anywhere user specific, the service could not import them, died in well under a second, and the service manager reported only SERVICE_STOPPED. That failure is reproduced exactly in the container: an interpreter without the packages exits 1 with an empty stdout and ModuleNotFoundError on stderr, which is precisely the empty service.log the field saw twice. The fix is one interpreter for the whole installation, the .venv the installation folder owns, used by console mode, by the preflight and by the service alike: it belongs to the machine rather than to a user account, LocalSystem can always read it, and no Python installed or removed later can disturb it. The installer now runs in six stages that answer the request behind this rebuild, which was to check the system and set everything up BEFORE installing anything: stage 1 inspects and changes nothing (Windows build, PowerShell, rights, disk, package completeness, Python and its version, Mosquitto, ports 8000 and 1883, existing services); stage 2 installs every dependency into .venv and then verifies them by actually importing them with the interpreter that will run the service, because pip reporting success is not the same fact as the service being able to import them; stage 3 configures the machine, opening the firewall for the console (inbound TCP 8000, which the guide used to ask operators to add by hand) and for BACnet (UDP 47808 to 47820) plus program rules for the platform and broker, disabling the stock mosquitto service, and clearing both ports, while deliberately never opening 1883 since the trial broker is loopback only; stage 4 is a preflight that starts the platform in the FOREGROUND with its output captured and waits for the console to answer, so a platform that cannot start prints its real traceback then and there and NOTHING is registered; stages 5 and 6 register the services and verify them. Two further blindness fixes, because an installation that fails must never again leave nothing to read: the services now run with unbuffered Python output, without which the log stays empty whatever the process wrote, since Python block buffers stdout as soon as it is a file rather than a console and the buffer dies unflushed with the process; and service logs are appended and rotated by size, so a crash loop leaves a history instead of one surviving line. install-everything.bat is now a launcher and nothing more, with no Python, package or firewall logic of its own to disagree with the installer. Finally the package verifier gained a rule that a virtual environment may never travel inside a package, proven by rejecting one built deliberately to contain it: a .venv is large, is built for the wrong platform whenever a package is assembled on Linux, and would be mirrored over a perfectly good local one by an update. |
| 2026.08.8-33 | A fresh install on a machine with an older Python already on its PATH now fails with one plain sentence instead of a silent, unexplained service crash (installer\install-windows.ps1, run_server.py; desk verified line by line, since PowerShell and a Windows service do not run in the development container). The field report was a beez-minibms service that reached SERVICE_STOPPED seconds after every start, on two separate attempts, with an entirely empty service.log both times: nothing to read, nothing to explain it. The cause traces to a version gate that fell out of step with its own codebase: app\main.py has used a multi line expression inside an f-string replacement field since earlier in this arc, legal only from Python 3.12 (PEP 701), and run.bat's own check was raised to require 3.12 when that landed, but the Windows installer's Find-Python was never raised to match and kept accepting 3.10. A machine that already had Python 3.10 or 3.11 on PATH, which is not unusual on a server that has hosted an earlier baseline of this same platform, passed that stale gate, passed pip install (nothing there is version sensitive), and only failed at the moment the service actually tried to import app.main: a SyntaxError, thrown before a single line of this platform's own code had run, which is also the likely reason nothing useful reached the log, since NSSM's redirection captures whatever the child process writes and a compile time failure this early leaves very little to capture. Two fixes, addressing both the gate and the blind spot behind it. Find-Python now asserts 3.12, matching run.bat and the Python this installer actually installs when none is found, so the mismatched machine is never accepted in the first place. Independently, run_server.py now checks sys.version_info before importing anything of this platform's own, in syntax deliberately plain enough to parse on any Python old enough to hit this, and prints one sentence naming the exact interpreter found, its path, and the fix, to both stdout and stderr, so a repeat of this failure mode is explained on sight rather than reproduced blind a second time regardless of what NSSM's log does or does not capture. |
| 2026.08.8-32 | The updater finds a renamed package (update.ps1 and the update guide; desk verified line by line, since PowerShell does not run in the development container). The field report was exact: the 8-31 package left the build named beez-minibms.zip, but the transfer pipeline it travelled through strips punctuation from filenames, it arrived as beezminibms.zip, and update.ps1's filters (the exact name in the installation folder, beez-minibms*.zip in Downloads) could not see it, so the update path found nothing. Browsers do the same kind of damage with the " (1)" suffix on a second download. The fix follows one principle: what a zip is called must never decide what it is. The search order is unchanged (an explicit path argument first, then the exact beez-minibms.zip in the installation folder), and then the pattern widens to beez*minibms*.zip, newest first, in the installation folder and in Downloads, which matches the canonical name, the stripped hyphen and the browser suffix alike; the package's own content, the wrapped beez-minibms folder with app\main.py and VERSION.txt, remains the only real gate, exactly as before, and the updater still shows what it picked and waits for confirmation before touching anything. The no-package message now names the tolerant pattern instead of only the canonical name. |
| 2026.08.8-31 | The Telegram connection workflow redesigned around the trusted bot and the pairing code, alarm alerts that carry their own action list, connected user management on one table, and the sub navigation rebuilt to the platform's own tab grammar (platform and console; runtime receipted against the live server with a mock Telegram endpoint: token validation, trusted monitor, code pairing with automatic chat id capture, regeneration, disconnect, and every alarm command including the new Close). Step 1 is now complete on its own: paste the BotFather token, validate it (one real getMe names the bot, @username, shown on the page and remembered so every pairing instruction can name the actual bot), save, and enable Trusted bot. Trusted is the new explicit switch that starts the continuous monitor on the bot's own message stream; pairing codes complete automatically the moment they arrive and the deterministic alarm commands are taken, independent of the AI ask switch, which now gates free questions only. Step 2 connects a user directly, no second wizard: pick the user, tick one or more projects (pairing now carries the full project set; schema v38), choose two-way or alerts only, and generate the six digit code; the page shows the code large with the exact sentence to pass on, and flips to Connected the moment the code lands. The chat id is read from the pairing message itself, always; every manual chat id field on the page is gone, deliberately, including the paste fallback. Step 3 manages the connected population in one table: chat id, projects, two-way, pending codes, with Save (projects and permission changes in place, audited), Regenerate code (same proof of possession, works for a new phone or an expired code, and the code is consumed by whoever holds it even if that chat was already paired, moving the chat id exactly as the audited manual move always did) and Disconnect (clears the link and revokes any pending code at any time, leaving the platform account untouched). Every alarm alert now ends with its alarm id and the full action list, each command carrying that id, because several alarms arriving together must never leave a reply ambiguous: Explain, Acknowledge, Assign (with or without the word to), Close, Shelve. Close is new and honest against ISA-18.2: an alarm whose condition has already returned to normal is closed outright by the acknowledge; one whose condition is still present is acknowledged with the plain statement that it stays live until it clears, and how to shelve it meanwhile. A user connected to several projects acts on any alarm their projects cover, since the alarm id names its own project; only free AI questions still require a single project scope, and the refusal says so. Finally the sub navigation (Admin, Maintenance, Bus Systems) stops looking like a placeholder: the subtabs now speak the exact same visual grammar as the main tabs, solid green pill when active, quiet tint on hover, uniform height, padding and typography, evenly sized in a contained bar, horizontally scrollable on a phone instead of wrapping into a heap. |
| 2026.08.8-30 | A better connection process for the bot, alarm actions that cost no token unless the action is Explain, maintenance from chat, and a visual pass on the admin pages (platform and console; new suite T104 green with eleven checks, T99 through T103 re run green, the whole loop proven live against a real worker raised alarm, a mock Telegram server and a mock AI provider). Pairing changes from copying a chat id by hand to a proof of possession: an admin picks a user and a project, mints a six digit code, and the person sends that code to the bot themselves, which is what proves they hold that phone rather than an admin typing a number copied from somewhere. The platform connects the chat id, sets whether the chat also answers questions and takes actions or is alerts only, and scopes the user to the chosen project, replacing any prior scope rather than adding beside it; a defect in exactly that replacement was caught and fixed before it shipped; the first version merged the new scope alongside an existing one, which would have left the ask engine facing the same ambiguity it already refuses to guess through. A short, human typeable reference code now travels with every alarm notification itself, minted the moment the alert fires, reused on a re-alert of the same alarm, forgotten once it clears, so a reply of explain 4821 or ack 4821 needs no lookup and carries no ambiguity when several alarms arrive together. That reply is matched by a fixed grammar before the AI provider is ever reached: acknowledge, shelve, unshelve and assign call the platform's own alarm functions directly and spend no token at all, and explain makes exactly the one call the console's own Explain button already makes, never more. Maintenance joins the same channel with no AI involved in any of it: my tasks lists a person's own open work, start and close act on it through the identical endpoint the console uses. Proving the alarm code path surfaced the sharpest lesson of the arc: the continuous alarm worker keeps its own state keyed by an alarm's id, independent of the rules table and outliving row deletion, since SQLite reuses a deleted row's id absent an explicit autoincrement; a fan out only ever fires on a genuine transition from normal to unack, and recreating a rule under the same id after deleting it is not that, which cost substantial live debugging before the fix was to inject a controlled transition directly at the bus topic the real worker publishes to, exercising the identical path production does without fighting debounce or digest timing. Separately, the admin pages some tabs looked worse than others: comparing every tab's markup showed Alarms and MQTT, which look fine, use the same all wide card pattern as Audit and Update, which do not, so card width was never the actual difference; the bot page had grown across five releases to eight cards where two were narrow and six were wide, leaving a lopsided partial row of empty space above a long wall of full width blocks, and the AI page crammed an unrelated form in under its main content. Both are fixed by consistency: every card on the bot page is now uniformly wide, matching the pattern that already reads cleanly elsewhere, and the AI page is split into two focused cards. |
| 2026.08.8-29 | The console's own alarm actions, reachable from Telegram, and a platform wide defect found while proving them (platform and console; suite T103 new and green with seventeen checks, T99 through T102 re run green, the full round trip proven with a real worker raised alarm, a mock Telegram server and a mock AI provider against the live running server). The request was plain: Explain, acknowledge, assign, everything the alarm row already does, answerable from a reply in Telegram. Five tools were added, acknowledge_alarm, shelve_alarm, unshelve_alarm, explain_alarm, assign_maintenance_task, and every one of them calls the exact function the console's own alarm row buttons call, never a second implementation that could drift from the first; the evidence assembly behind Explain was extracted into one shared function so the console and Telegram can never see a different picture of the same alarm. A write is only taken once the alarm key is confirmed active in the caller's own project right now, never guessed from a fuzzy description, and only once the real paired platform user's own role clears operator; a viewer who tries is refused in plain words naming who can fix it. Every write is attributed to that real user through a new canonical builder that reproduces the exact shape a login token produces, and is audited with the Telegram trail visible in the detail. Explaining an alarm this way makes the same AI call the console's Explain button already makes, at the same cost, on the same configured provider; acknowledging, shelving, unshelving and logging a task make no AI call at all. Proving assign_maintenance_task against a real alarm surfaced a defect with nothing to do with Telegram: four places in the platform fetched a row's id immediately after inserting it with a separate query that, since the read and write connections were split for performance, always returns zero on a connection that has never itself written anything. One consequence was silent and total: creating a maintenance task from the console has returned Task not found on every single call since that split, receipted here by reproducing the exact failure on the plain console path with no Telegram involved, then proving the fix the same way. The other three sites, two Control Engine review paths and the boost definition endpoint, carried the same defect and are fixed by the same rule, use the value the insert already returns; the codebase is swept clean of the pattern that caused it, which had been found and fixed once before in one place and never generalised. |
| 2026.08.8-28 | Inbound messages arrive on an inspected network, and alarm routing is decided on one page (platform and console; T102 extended and green with sixty two checks, T101 re run green, the routing loop run live end to end, route then read back then remove). The field had trust configured and outbound working, yet inbound messages still never appeared, and the cause was one omission of this arc's own making: the ask poller was left on default trust when the TLS settings arrived, so on an inspected network every poll failed with the same certificate error that sending had just been cured of, silently, visible only as the last error in the ask status. The poller now builds its connection from the same trust settings as the sender, so the two can never again disagree about whom they trust. On the polling worry, the numbers deserve stating because they are reassuring: the poller uses long polling, one request that Telegram itself holds open for forty five seconds and answers early only when a message arrives, so an idle bot costs roughly eighty requests an hour, a few bytes each, no database writes, and messages are kept in a bounded in memory list of twenty; this is the mechanism Telegram documents for exactly this purpose, and it cannot fill the platform. Alarm routing gains the missing administrative half. Until now a project's contacts received fault and control events and each user had to subscribe themselves; the new Who receives what table in the outbound card shows every user for the chosen project with their subscription state and their reachability, and an admin ticks the categories, picks the minimum severity and the channels, and saves on the user's row. A channel the user cannot receive on, no Telegram pairing or no email address, is shown disabled with the reason rather than hidden, so an impossible route cannot be configured looking as though it works. Routing and unrouting another user's alarms is audited with the full decision in the detail. |
| 2026.08.8-27 | Pinning actually pins, and a trust failure reports every attempt in full (platform and console; T102 extended and green with fifty seven checks, the field's exact chain rebuilt in the rig with a proxy style CA whose root travels inside the presented chain). The field ran the automatic trust and it failed twice, then reported a message that ended mid sentence, which hid the third attempt entirely; a truncated diagnosis is how a defect hides, so the truncation itself was the first fix. The failure answer is now structured, one line per attempt, shown in full on the page, and a total failure still names the organisation whose certificate is doing the intercepting, because that names the product whose root certificate the site should request from IT. Behind the truncation sat a real defect in the pinning fallback: OpenSSL refuses a certificate as a trust anchor unless it is self signed, and the certificate a real proxy presents is issued by the proxy's own CA, so pinning it verified nothing. The 8-26 receipt passed only because that rig's certificate happened to be self signed, which is a lesson recorded here as much as a fix: a rig must reproduce the field's shape, not a convenient one. The bundle context now sets the partial chain flag, which is precisely what pinning means, and the default context is unchanged and strict. A second quiet defect fixed alongside: an unverified handshake parses no certificate fields in Python, so the issuer was always reported empty; it is now read from the raw DER, and the rig proves the field scenario end to end, the presented certificate captured, its issuer named, and a real call verified against it with verification on. The Windows certificate store attempt remains in place and remains desk verified; on the field server it built a bundle that still failed, which is itself informative, because it means the intercepting authority is not in that machine's store and the browser test that succeeded was very likely run from a different machine on a path that is not intercepted. |
| 2026.08.8-26 | Trust configures itself, verified before it is kept (platform and console; T102 extended and green with fifty one checks, the whole route proven against a self signed HTTPS server that reproduces the field failure). The operator asked the right question: why should anyone hand craft a certificate path at all. One button now does it, and the order it tries things is the whole design. First it simply calls Telegram with the standard trusted authorities, because a network that does not intercept needs nothing and should be told so rather than left configuring a fix for a problem it does not have. Then, and this is the case the field actually hit, it reads the machine's own certificate store, which is Windows only by construction and is exactly why a browser on that same server reaches Telegram while the platform cannot: a firewall doing TLS inspection installs its authority into the Windows store, every browser reads that store, and Python carries its own separate idea of who to trust. Handing that store to Python is therefore not a workaround, it is the same trust the rest of the machine already uses. Only if that is unavailable does it fall back to pinning the certificate actually presented, which is reported honestly as the weaker remedy because it works today and breaks the moment the proxy re issues, with the advice to obtain the authority's root certificate for a durable fix. Every step is verified with a real call before anything is written, so a trust setting that does not actually work can never be saved as though it did, and the automatic route never turns verification off: that remains a separate, deliberate, audited choice. The Windows store path is desk verified only, since this development container is Linux; the fallback, the verification gate and the refusal to save a failing setting are all runtime receipted. |
| 2026.08.8-25 | Telegram works from behind a TLS inspecting firewall (platform and console; T102 extended and green with forty five checks, every state proven against a genuinely self signed HTTPS server that reproduces the field failure exactly). The field receipt arrived as a delivery log line, which is the first time this platform's own logging closed a diagnosis without a single question being asked: certificate verify failed, self-signed certificate in certificate chain. That is not a Telegram fault and not a bot fault. It means something between the server and Telegram terminates the connection and re-signs it with its own authority, which is ordinary on networks running a corporate firewall or antivirus with TLS inspection, and Python correctly refuses to trust it. Outbound notifications now build their TLS context from configuration rather than assuming the default. Naming the intercepting authority's root certificate, in PEM form, on a path on the server, makes verification succeed against it, which is the correct fix and keeps the guarantee that the platform is talking to Telegram. Verification can instead be switched off, and that is deliberately awkward: it demands a confirmation stating in plain words that messages stay encrypted but the platform can no longer prove who is on the other end, and it is written to the audit trail. A CA bundle path that does not exist is refused when saved and fails loudly when used, rather than quietly falling back to system trust and leaving an operator to wonder which one is in force. The same trust settings apply to the inbound reader, so the debug card and the sender never disagree about what they trust. Transport failures are translated the way rejections already were: a self signed chain names TLS inspection and the remedy, a resolver failure points at DNS and outbound access, a timeout points at the firewall, a refusal points at a proxy, and anything unrecognised gets its own text with no invented advice. One related fix carried in the same release: the sender's timeout and context are now shared with every path, so nothing reaches Telegram with different trust than the page reports. |
| 2026.08.8-24 | Telegram failures explain themselves, and the inbound stream is readable from the console (platform and console; T102 extended and green with thirty three checks, the whole path proven against a mock Telegram that rejects exactly as the real one does). The field report was that a token was correct and a chat id was in hand, yet the test message never arrived and nothing said why. It did not say why because of a real defect: Telegram answers a rejection with an HTTP 4xx whose BODY carries the only useful part, the description, and the send path let urlopen raise on the status code and threw the body away. Every failure therefore looked identical and told an operator nothing. The body is now read and the description reported verbatim, with the remedy for the rejections that actually happen appended in plain words rather than replacing it: chat not found says to check the number and to make sure the person has opened the bot and pressed Start at least once, because a bot may never write first, which is the single most common cause of a test that vanishes; blocked says the recipient must unblock the bot; unauthorized says the token is wrong or revoked. An unrecognised rejection gets no invented advice, only its own description. The delivery log shows that reason in full rather than truncated. A second, quieter defect surfaced while proving this and is fixed: the durable queue sender was handed only the notify configuration, so a site pointing the bot at a proxy through the telegram api_base setting was silently ignored by every queued alarm while direct sends honoured it. For configuring a bot, a new card reads the inbound stream server side with the stored token, which is the getUpdates view an operator would otherwise reach by pasting a token into a browser address bar; here the token never leaves the server. It lists each message with its chat id, sender, text and whether that id is already paired, and offers the id straight into the pairing field. One honest caveat travels with the answer rather than being discovered the hard way: Telegram hands each message over once per bot, so while Ask on Telegram is consuming them this card shows the poller's own record instead and says so. That distinction is now made correctly: the poller thread stays alive and idle while the feature is off, so liveness was the wrong question and a new consuming check asks whether it is enabled and holds a token, which is the only state in which nobody else can read the raw stream. |
| 2026.08.8-23 | The bot page answers the three questions an operator actually has, and every admin pane keeps the same width (console, one new endpoint, one poller addition; T102 extended and green with twenty three checks, T101 re run green after the poller change). Three field issues, all fair. First, the chat id: it is a number Telegram issues for a conversation, not something anyone can invent, and the page previously asked for it without saying where to get it. Card two now says so plainly and offers Find recent senders, which lists whoever has just written to the bot with their id, their name and their message, and a button that fills the field in; already paired senders are shown as such rather than offered again. Because Telegram refuses a second concurrent getUpdates on one token, the ask poller records what it already sees and the console reads that when the poller is running, falling back to one short poll of its own when it is not, so the two never fight over the same stream. Second, nothing explained why a message never arrived: card six is now a delivery log reading the platform's own notification log, filtered to Telegram by default, showing what was tried, to whom, and exactly what came back, and the test button now points at it. A message that fails now fails visibly. Third, the outbound configuration was described on this page but lived elsewhere, which is a page that explains rather than does: project contacts (who receives every fault and control event immediately) and the caller's own subscription (categories, minimum severity, channels) are both configured here now. Separately, the layout complaint was correct and structural: admin panes that wrap their cards in the shared grid flow into tidy columns, while panes that place cards directly in the section stretch the full panel width, which is why Projects and MQTT looked composed while Relations and the bot page did not. Both now use the same grid as every other pane, with genuinely wide content (the relations canvas, the wizard, the tables) marked to span all columns, which is what that class exists for. One markup defect introduced and caught during the restructure is owned: an unbalanced div left the relations grid unclosed, found by a depth walk over the pane rather than by eye, and the whole document is now balanced at 285 open and 285 close. |
| 2026.08.8-22 | The beezminiBMS_bot tab is actually visible, and the admin switcher can never lose a pane again (console only; T102 extended and green with eighteen checks; the outbound Telegram path separately proven end to end against a mock Telegram API). Field report: the tab shipped in 8-21 displayed nothing at all. Reproduced in a headless browser before any fix, and the cause was mine and embarrassing in the useful way. The admin sub tab switcher does two things when a tab is clicked: it unhides the matching pane, and it calls that pane's loader. The new tab was added to the loader but not to the hardcoded array of pane names that does the unhiding, so clicking it dutifully fetched the token status, the ask engine state and the user list into a section that stayed hidden. Nothing errored, nothing logged, the page was simply blank, which is the worst kind of defect because it looks like a dead feature rather than a wiring mistake. The fix is structural rather than a longer list: the switcher now derives its panes from the DOM, hiding every admin section except the one whose id matches the selected tab, so any future pane works the moment its markup exists. The Bus tab's own panes share the same class but use a different id prefix and are correctly left alone, which the probe confirms. A headless probe now walks every admin tab in turn and asserts that each reveals exactly its own pane and no other; all eleven pass. Two independent verifications were run alongside, since a blank page casts doubt on everything behind it: the outbound notification path was driven against a mock Telegram API through both the direct send and the durable queue, proving alarms reach the bot as designed; and every endpoint the tab reads on load answers correctly on a live server. The Intent pane, added earlier in this same arc, was checked for the identical defect and is clean. |
| 2026.08.8-21 | beezminiBMS_bot: the whole Telegram surface in one Admin tab (console and one users listing addition; T102 green with sixteen checks, T101 re run green to confirm the ask engine still works after the listing change). The gap this closes was named plainly: 8-20 shipped a working ask the plant engine reachable only by calling its API directly, with no way in the console to turn it on, see who was paired, or check its status. The new tab is the whole surface, both directions. Card one is the bot token itself, write once and never shown again, with the write controls hidden for anyone below superadmin. Card two lets any admin pair their own Telegram chat id and send themselves a test message without leaving the page. Card three states plainly that outbound alarm delivery is unchanged and configured elsewhere (project contacts and subscriptions), and confirms only that the channel under it is alive. Card four is the ask engine itself: a toggle that calls the real endpoint, a running indicator, and a status table reporting the provider in use, messages seen, answered and dropped, and the last error, with a warning that distinguishes a missing bot token from a missing AI provider rather than leaving an operator to guess which. Card five lists every platform user with an inline editable Telegram chat id, so pairing the whole team happens in one place instead of visiting each user's own settings. Two real defects were caught while wiring this and are owned rather than shipped quietly. The self pairing save would have silently overwritten a user's saved email with an empty string on every save, because the underlying notify endpoint writes both fields together; the fix looks up the current email first, which is always possible here since reaching this tab already requires admin rank. And the ask engine's status endpoint is superadmin only while the tab itself is admin only, so a plain admin opening the page would have hit a raw permission error; the tab now says plainly that the panel is superadmin only. The users listing gained the email and Telegram chat id columns this page needs, both already exposed per user elsewhere in the platform and neither a secret. |
| 2026.08.8-20 | Ask the plant on Telegram (platform only, two new modules, one new pairing rule; T101 green with sixteen checks, the whole loop run for real against a local mock Telegram server and a local mock provider). The outbound half has existed since the notifications wave: alarms already reach Telegram through the durable queued sender. This release adds the inbound half the architecture note chose over the Claude Code Channels preview: the same bot now answers questions like what alarms do I have, is my chiller running, any humidity deviations, and the model never touches the host. The engine gives the configured AI provider exactly three read only tools, the live alarm snapshot, a point search over live values, and a one equipment summary, all ordinary functions over the platform's own cache and database; the model names a tool, the platform runs the query, the model phrases the answer, five rounds at most, tool output bounded so a five thousand point site cannot blow the context, and a tool failure is an answer that names the failure. The provider is whatever Admin, AI already selected, both wire formats through the standing shim, so one configuration powers Explain, the research loop and this. The poller is one long poll loop per platform, because Telegram answers a second concurrent getUpdates with 409; it starts with the server, idles while disabled or unconfigured, and stops cleanly at shutdown. The allowlist is the users table: a chat id no platform user has paired is dropped without any reply, so strangers get no surface at all, and the answering user's project scope follows the v16 model, the explicit user projects mapping first, then the legacy column, then the tenant, refusing only on genuine ambiguity rather than guessing the building. The suite caught one real product defect on the way: two users could hold the same Telegram chat id, and the bot answered as whichever the database returned first, in the field case an unscoped admin. Pairing is now one to one: setting a chat id unpairs any previous holder, visibly in the audit trail, because a chat id names a person and two accounts answering for one person is ambiguity by construction. A superadmin endpoint reports the bot's state, the paired users and the provider, and toggles the feature; enabling it needs three things that already exist in Admin: the bot token, an AI provider with a key, and a chat id on each user who may ask. One correction owed to the 8-19 changelog: the console smoke suite's navigation error is intermittent, not fixed; it was absent on the 8-19 run and returned on this one, so the honest smoke baseline remains nineteen to twenty passed with the Points versus Graphics tree comparison standing. |
| 2026.08.8-19 | The network wide BACnet schedule browser (bridge driver, platform and console; T100 green with eighteen checks; the clear write path is desk verified because the lab simulator owns no Schedule object, and Nico's run is its field test). The Intent page shows what a project was told to do; this shows what the controllers actually hold, so the two can finally be compared. Scan the network runs a Who-Is campaign on the chosen bridge, then sweeps every device that answered for Schedule and Calendar objects, riding the object list ladder so a weak controller is handled exactly as every other browse handles it, and reads each schedule's weekly plan and present value. A device that fails its object list is skipped with its reason rather than failing the sweep, and the sweep can be cancelled between devices. The weekly parser is pure and is the part this release tests hardest, because the field produces several shapes: the stringified form that appears in the site registers, decoded time and value pairs in any order, stack objects carrying a day schedule attribute, and a dictionary keyed by day name. Times normalise to 24 hour HH:MM and sort; active and inactive become 1 and 0; null stays null; and anything unrecognisable yields an empty day rather than an invented event. Clearing is called clearing, and it is honest about what BACnet actually allows. Object deletion is optional in the standard and most controllers refuse it, so the capability offered is seven empty days written to the weekly schedule, verified by reading the object back: if events remain, the result is a failure that says the device did not accept an empty schedule, and if the write lands, the message states plainly that the object commands nothing now and that the object itself still exists. The action demands a typed confirmation and the card explains all of this before it is used, rather than offering a delete button that would silently fail. One incidental improvement, receipted rather than claimed: the console smoke suite now reports twenty passed and one failed, where the standing baseline was nineteen and two, the jsdom navigation error having gone with this release's script changes; the remaining failure is the long standing Points versus Graphics tree comparison. |
| 2026.08.8-18 | The commissioning intent has a page (console only, no schema change; T99 extended and green with twenty seven checks). Admin gains an Intent sub tab that reads the two plan endpoints shipped in 8-17 and shows what a project was told to do: every imported setpoint with its kind, its state, its value and unit, its tolerance, whether the point is writable, when it took effect and where it came from; and every imported schedule with its BACnet object name, a one line week summary, its exception count, and a Week button that prints the plan day by day with each event's time and value, exceptions included. Both tables share a project selector, a status filter and a text filter that hides rows in both at once, so a question like what did we agree for level 2 is one word away. The status column carries the distinction the whole schema exists to protect, and the interface says it in plain words on hover: implemented is live on site, agreed is client agreed but not yet written, proposed is a beez-fm recommendation, and current is only what was observed on a controller. A project with no imported intent says so and names what would fill the page, rather than showing an empty table with no explanation. Nothing on this page writes: it is the record of intent beside the live values the rest of the console already shows, and the difference between the two is the commissioning question worth asking. The network wide BACnet schedule browser, which matches these plans to the real Schedule objects on the wire and offers modify and clear, is the next release in this line. |
| 2026.08.8-17 | Bundle schema 3: the commissioning intent travels with the project (platform only, two new tables, no driver change; T99 green with twenty three checks, a schema 3 bundle imported end to end and re-imported corrected). The finding came from the CDG-SG-SETSCO R2 bundle: it carried 1612 relationship rows, 598 setpoints and 1972 schedule rows, and the platform imported none of them, because those registers were documentation and bundle.json had no blocks for them. The relationship graph already had a home and needed nothing; setpoints and schedules did not, and now do. A bundle may declare schema 3, flat or nested, and carry a setpoints block and a schedules block beside the points, the graphics and the relationships; schema 1 and 2 bundles import exactly as before with empty blocks, so nothing existing changes. Setpoints record the value that is in force with its unit, its tolerance, its state (in shift and out of shift are separate entries, never averaged into one), whether the point is writable, and a status that keeps a recommendation from being mistaken for reality: implemented and agreed describe what the building should do, proposed is a beez-fm recommendation, current is only an observation of what a controller reads today. Schedules record one entry per schedule with the week inside it, events normalised to 24 hour HH:MM and sorted, exceptions and public holidays kept, and the BACnet Schedule object name preserved, which is what will let a plan be matched to a real object on the network. Both carry their basis and their source document, because a setpoint without provenance is a rumour. Refusals never kill an import: a value that is not a number, a day that is not a day, an equipment that is missing, each comes back as a warning naming the entry while the rest of the bundle lands, and the import summary now reports how many setpoints and schedules were taken. Re-importing a corrected bundle replaces the entries for the same equipment, kind and state rather than duplicating them, which is what makes a bundle worth correcting. Two read endpoints serve the result with status filters and a one line summary per schedule. The console view of both, and the network wide BACnet schedule browser with modify and clear, are the next releases in this line; the honest constraint to carry into that work is that BACnet has no universal delete for a Schedule object, so where a controller refuses object deletion the capability is to clear the schedule and say so rather than offer a button that silently fails. |
| 2026.08.8-16 | Large site hardening: the COV ramp is paced and the freeze dump is capped (bridge driver only; T98 green with thirteen checks; T90 and T97 re-run green against the change). The evidence came from the first real installation at scale, BEEZ-SERVER-02 running 4965 points across 82 devices in direct mode, and it is worth stating what the log proves before what it exposes: every device resolved from the cache, 600 COV subscriptions were accepted, and many turned COV_VERIFIED on live notifications, so the BACnet layer works at production scale. Then the event loop froze for 15 seconds, then 75, then 135 and climbing, and the freeze detector named the blocking frame exactly as it was built to: bacpypes3's refresh_subscription inside BAC0's CoV task. The cause was ours. The subscribe loop created every subscription back to back with no yield between them, so a burst of six hundred swamped the very loop it runs on. Subscriptions are now issued one at a time with a calm 25 millisecond breath between them, the same pacing the browser ladder uses, and subscribe_all became a coroutine so it genuinely yields mid ramp with all four call sites awaiting it; a 600 point ramp completes in about fifteen seconds while the loop keeps answering throughout, and the suite proves the interleaving rather than assuming it. The same field log arrived at 1,068,545 lines, almost all of it repeated thread dumps of that one frame, which buries the forensics the detector exists to provide: full dumps are now capped at five per run, after which the freeze is still reported on one line with the cap stated, so an hour of freezing costs five dumps instead of one hundred and twenty. Two honest notes. The missing point values on that server are not this defect: the broker log is zero bytes, mosquitto has never started there, and values, writes, alarms and the control engine all ride that bus even though the live browser no longer does since 8-14. And T91 carries one failing check that is a rig artifact, not a product defect: the platform's own configuration path replaces a bridge's settings wholesale rather than merging them, verified by reading it, and a direct probe on a clean bridge announced controller 999 policy override: unconfirmed, lifetime=123s exactly as designed, so the suite's start sequencing is the item to fix, and it is registered rather than rushed. |
| 2026.08.8-15 | Relations redesigned: the drawn graph leads, loops are real, floors drive bulk wiring (platform, console, one new table; T97 green with twelve checks; two build defects caught by the suite and owned below). The field review said the old page mixed statistics with topology, and it did: the proposals are alarm co-occurrence, controllers and machines alike, and leads meant raises-first, never airflow. The page now reads in the order the work happens. The canvas sits on top with a new Focus system selector: pick one machine and the drawing narrows to its world, first hop everything touching it, second hop following loops, so an air handler's view shows its chilled water loop AND the chillers feeding that loop, with a one line summary of every connection by medium. Below it, Quick connect implements the five step flow from the review: pick the medium, pick the supplier (loops lead the list for water and power, machines for air), tick the floors, checkbox the equipment on those floors with All and None, and create everything in one call, capped at two hundred edges, validated all or nothing, one audit row, one graph snapshot. Floors come from a pure parser reading L2, B1, GF, RF and M2 out of equipment names across dash, underscore and space conventions; unparsed names land in an unassigned bucket rather than being guessed. Hydronic and electrical loops are first class nodes: six kinds (chilled water primary and secondary, condenser, hot water, power, refrigerant for VRV and CU), created in the wizard, drawn as loop: nodes on the canvas, deleted with their edges; the vocabulary gained supplies condenser water to and supplies refrigerant to. The old proposals survive demoted and honest: renamed Suggestions from the alarm history, collapsed by default, filterable, their columns reading Raises first, Raises after and Typical gap, with the text stating plainly that co-occurrence is evidence for plumbing and never proof of it. Two build receipts owned: the loops table first rode a multi statement execute the schema layer refuses, and the new loop endpoints first collided with the Control Engine's standing /api/loops, both caught by the suite before the gate; the relation endpoints now live under /api/rel-loops. |
| 2026.08.8-14 | The live browser no longer needs the broker (bridge driver, platform and console; T96 rewritten and green with seven checks, a whole Who-Is campaign run to completion with no broker on the machine at all). The BEEZ-SERVER-02 logs settled two questions at once. First, the broker outage had nothing to do with projects: broker.log was empty, meaning mosquitto had never run on that server, and both bridge logs end at stack initialisation with no mqtt connected line, endlessly retrying a broker that was never there; the bridges themselves were meanwhile perfectly configured, real masks, correct broadcasts, port 47808. Second, the operator's architectural demand stood on its merits: discovery is the tool of onboarding, and onboarding is exactly when the message bus may not exist yet, so requiring the whole data plane to send one Who-Is was backwards. Browse commands therefore gain a broker independent file channel: whenever the platform's broker connection is down, each command travels as one json file into data/browse-queue/(bridge name), a supervised watcher inside the bridge picks it up within half a second and feeds it into exactly the same handler the MQTT channel uses, and the answers travel back as progress and result files the job poll folds in; cancellation rides the same directory. MQTT remains the primary channel and the fleet transport for values, writes and status, unchanged; the 8-13 banner stays because a down broker still silences the rest of the data plane. One build receipt owned: the first cut of the platform half accidentally re homed the job endpoint's route decorator onto the new helper function, breaking the poll with a validation error; the receipt was one curl showing the 422 body, and the standing lesson about anchoring edits against decorators is recorded here. The bridges on the field server carry device instances that change on every restart, visible in the uploaded logs; that is the stack's default identity and a register item for CONF-001 with Didier, not a defect. |
| 2026.08.8-13 | The broker outage is named at start, platform wide (platform and console; T96 green with six checks, the dev broker killed and revived mid suite with clean recovery). Field receipt from BEEZ-SERVER-02, and the first for the 8-12 liveness layer: the panel named a down broker as the reason a Who-Is never reached the bridge, which is exactly the sentence the platform could not say two releases ago. To the architectural question the finding raises: browse commands riding MQTT is the fleet design, not a legacy accident. The bridges are isolated processes that can even run on other machines, and every value, write, status and command in the platform travels the one transport with the one naming schema; a second, private channel just for the browser would fork the architecture to paper over a broker outage that silently breaks everything else anyway. The correct hardening is therefore visibility and early refusal, which this release ships: browser start answers 409 with the remedy while the broker is down, naming the beez-mqtt service and its log at data/broker.log, so no doomed job is created for the poll watchdog to catch twenty seconds later; and the console shows a platform wide banner whenever the broker connection drops, in the standing alarm colour, because a down broker takes the entire data plane with it and deserves more than a grey dot in the header. The suite kills the broker mid run: the platform notices, refuses with the service name, reconnects when the broker returns, and accepts the next job cleanly. |
| 2026.08.8-12 | Browse jobs fail with names, never hang (bridge driver, platform and console; T95 green with six checks including the dead bridge named on the very next poll). Field report from BEEZ-SERVER-02: a Who-Is stayed blocked at the campaign's starting message with no progress and no end. The start endpoint had answered 200, so the bridge process was alive at that instant, which leaves exactly two silent infrastructure failures, both already met in the lab: the platform's broker publish quietly doing nothing while the API answers normally, the T86 signature, or a bridge process whose BACnet stack never came up, classically because another application already owns the port, the desktop browser on 47808 being the obvious candidate on a machine where both tools run. Both failures were invisible to the operator. Three layers now make them speak. The driver acknowledges every browse command on the progress channel the instant it arrives, before any operation runs, so absence of the acknowledgement is a verdict about the infrastructure, never about a slow device. The job poll answer carries bridge_running and broker_connected beside the job, straight from what the platform already knows. And the console turns these into named failures within seconds: a disconnected broker is reported as the reason the job never reached the bridge; a dead bridge process is reported with the port collision hint and where its log lives; and when both report alive yet no acknowledgement lands within twenty seconds, the message points at the bridge log rather than leaving a spinner. The eternal starting message of the field report is now impossible by construction: every waiting state either progresses, completes, or names the component that failed. |
| 2026.08.8-11 | Who-Is sweeps go where devices listen (bridge driver only; T94 green with eight checks; the root cause receipted three ways: two field pcapng captures from BEEZ-SERVER-02 and one lab tcpdump). Nico recorded the wire while pressing Who-Is, once with the firewall on and once fully off, and the captures rewrote the story: the miniBMS bridges were alive on the network the whole time, answering a foreign Who-Is with unicast I-Am from both server addresses and serving ReadProperty and ReadPropertyMultiple conversations to the other BMS, while emitting zero broadcast frames in either capture. The lab tcpdump then completed the diagnosis: the stack broadcasts Who-Is to the local broadcast at its own port, so a bridge on 47817 shouts at 127.255.255.255:47817 where no device has a socket, and on the field Windows server even that default broadcast never left the machine while every unicast frame flowed. The fix rides the proven path: every campaign sweep now additionally sends a directed Who-Is to the bridge's subnet broadcast at port 47808, computed from the configured mask, the same sendto path the captures show working, with the destination stated in the sweep's own log line so Wireshark and the bridge log agree; a failed directed send is logged in full rather than swallowed. The lab receipt after the fix shows the sweeps arriving at 127.255.255.255:47808. The empty campaign diagnosis is reworded to the new physics: unicast repliers now register from any port, while devices that broadcast their I-Am to 47808 still only reach a bridge on 47808, so 47808 remains the recommendation for the discovery bridge. The mask matters doubly now: the directed broadcast is computed from it, so /8 on a routed LAN aims at an address the switches will not deliver, exactly what the standing diagnosis already tells the operator to fix. |
| 2026.08.8-10 | Firewall opener finds the real venv (tools and suite only, no product code; T93 green with ten checks). Field receipt from BEEZ-SERVER-02, first live run of open-firewall.ps1: the port rule set correctly, then the program rule was skipped because the script looked for venv while run.bat creates .venv. The script now tries .venv first and venv second, and its message when neither exists says plainly that the port rule alone already covers the whole standard bridge range, so nothing is missing, rather than implying a gap. The suite pins the search order. Operationally the 8-9 run on that server is complete as it stands: inbound BACnet UDP 47808 to 47899 is open, which is everything the bridges need; re-running the script after this update merely adds the belt and braces per program rule. |
| 2026.08.8-9 | The empty campaign speaks: Who-Is diagnosis and the firewall opener (bridge driver, console, installers and a new tool; no schema change; T93 green with nine checks; the firewall work is Windows and desk verified, Nico's run is its live test). Field finding from BEEZ-SERVER-02: a Who-Is from the platform browser found nothing while the desktop tool on the same network found everything. Three stacked physics explain it, and the desktop tool dodges all three: it owns port 47808 so it hears the I-Am broadcasts that devices send there, while a platform bridge on 47809 and up sends a perfectly good Who-Is and then cannot hear the broadcast replies, only unicast ones; it received its Windows firewall prompt on first Who-Is, which a bridge spawned by a session zero service never gets, so inbound UDP is silently dropped; and it broadcasts from the interface's real mask, while the bridge convention of /8 on a routed LAN aims the Who-Is at a directed broadcast the switches will not deliver. The fix has two halves. The driver half: a campaign that finds nothing now returns a diagnosis naming this bridge's own port against 47808 with the remedy (run the discovery bridge on 47808 if nothing else owns it, or add devices by IP, which is unicast and works from any port), the firewall script, and, when the mask is /8 on a non loopback address, the real subnet mask to set; the console shows this sentence where the silent zero used to sit, and a campaign that finds devices carries no diagnosis at all. The tooling half: tools/open-firewall.ps1, idempotent and self elevating, allows inbound BACnet UDP 47808 to 47899 plus the installation's own venv python, is called by install-windows.ps1 on fresh installs, and is removed by uninstall-windows.ps1; existing installations run it once as administrator. For BEEZ-SERVER-02 specifically: run the firewall script, then set the discovery bridge to port 47808 with the server's real mask, closing the desktop tool first, since two BACnet applications cannot share the port on one machine. |
| 2026.08.8-8 | The controller assessment: one telegram tells you the transport, the stopwatch tells you the cadence (bridge driver, platform and console; no schema change; T92 green with twelve checks, the pure math in both directions and the live verdict verified by a real notification). The Assess controller button in the transport policy editor runs one bounded campaign against the selected controller: a real SubscribeCOV telegram whose verdict is read from what actually happened on the wire, verified when a live notification arrives inside the ten second window, accepted when the subscription stands but nothing fires, which quiet points legitimately do, or refused with the device's error verbatim; ten timed reads giving minimum, average and maximum round trip; and one live ReadPropertyMultiple check. From these the recommendation is computed by a pure, suite tested function whose model is stated in its own sentence: one transaction per point, or per chunk of twenty when ReadPropertyMultiple answers, each costing the measured average round trip plus the calm pacing gap, the recommended interval asking for five cycle times so the controller stays near twenty percent bus duty, floored at three seconds, every number labelled the estimate it is. The arithmetic speaks the big network truth directly: four hundred points on a controller with ReadPropertyMultiple recommend five seconds, the same controller without it recommends seventy four, which is exactly the overload a large site walks into unwarned. A refused verdict prefills the policy editor's transport to poll, one Save away from taking effect; probe object selection prefers real point families after the stack's own networkPort object, which never notifies, was the first pick once. A controller that answers nothing gets no estimate and is told so plainly. The assessment writes nothing and subscribes for sixty seconds at most, cancelled on completion; it is a diagnosis, not a change. |
| 2026.08.8-7 | Per controller transport policy, set from the BACnet browser (bridge driver, platform and console; config schema only, no database change; T91 green with eleven checks covering the endpoint, the spawner and the live driver obeying both directions of the override). Every controller a bridge talks to can now carry its own transport policy, keyed by device instance: COV or poll, confirmed or unconfirmed notifications, and its own subscription lifetime, each independently, with absent keys inheriting the bridge defaults exactly as before, so every existing configuration behaves unchanged. The policy lives on the bridge entry in the configuration, the spawner hands it to the driver at start, and the driver announces every override in one log line at startup so the effective policy is always stated, never assumed. A controller set to poll has its points excluded from the COV shortlist and served by the established heartbeat path (three times the interval, the F-009 machinery), with the exclusion logged and the freed budget going to controllers that subscribe well; lifetime and confirmation overrides apply at subscription, at renewal, and at the capability probe, and the renewal timer follows the earliest lifetime among a source's chosen points so no override can lapse. In the browser, every device row gains a Transport button opening the policy editor: the bridge defaults shown beside the three fields so inherit means something visible, saving through a superadmin endpoint with range validation (lifetime 60 to 86400 seconds), one audit row per change, and a restart button because the policy applies at bridge start, which the interface says rather than implies. The intended workflow is exactly the one requested: discover a controller in the browser, judge its behaviour from the ladder and the grid, and set its transport policy on the spot; the sick controller that drops subscriptions gets polled, the chatty one gets a short lifetime, the battery device gets unconfirmed, and none of it disturbs its neighbours. |
| 2026.08.8-6 | The production driver learns the browser's lessons (bridge driver only, no schema change; T90 green with seven checks, the full production write loop receipted end to end for the first time in the platform's history). The audit the release comes from: the browser parity work proved in R4 that the stack's public write is fire and forget and swallows rejections, and the production driver carried exactly that pattern at both of its write sites, the /set presentValue write serving the Control Engine, the console and the guest room pages, and the schedule write; every field write in the platform was captured ok after nine milliseconds whether or not the device ever acknowledged. Both sites now await the underlying SimpleACK with an explicit timeout, a timeout or a raised rejection is captured as the failure it is, and the /set path reads the object back after the acknowledgement and logs what it actually holds, without false alarming when a higher priority slot legitimately holds the object. The schedule write's own long standing caveat softens accordingly: accepted now means acked rather than merely sent. Two receipts shaped the details: a 32 bit BACnet Real hands 66.6 back as 66.5999984741211, so the readback comparison uses single precision tolerance rather than crying refusal at the seventh decimal; and one suite run lost a quality of service zero publish entirely, so the suite publishes confirmed at quality one with a single bounded retry and reads only the newest log segment, a rig hardening that changed no product code. The classifier sweep found no further instance of the TimeoutError repr trap: the poller's response object classifier types values, not exception text, and stands. T90's receipt is the sentence the platform could not truthfully say before this release: a source polls the live simulator, a fleet style set publish arrives, the write is acknowledged by the device, the readback matches at the wire's own precision, and the next poll confirms the new value on MQTT. The hardware half of FAT campaign H remains the field's to close, now against a driver that no longer takes silence for consent. |
| 2026.08.8-5 | BRW R5: the batch priority write list and the whole network EDE, closing the parity build scope (bridge driver, platform and console; no schema change; T89 green with fourteen checks including the functional specification's own example lines parsed verbatim and the importer round trip). BRW-040/041: the browser gains the desktop tool's batch write, accepting its exact file format so field lists move between the two tools unchanged: five comma separated fields, device ID, object type as abbreviation or numeric, object instance, priority with the optional p prefix, value with NULL releasing the slot, comments and blanks tolerated. Parsing is strict and completes on the bridge before any write, a single malformed line aborts the entire run untouched with its line number, a device that parses but is not registered on the panel fails its rows without aborting the run, execution demands the operator type the row count, rows pace at fifty milliseconds with one log line each, and cancellation acts between rows through the R2 channel. BRW-050 to 052: Export network EDE walks every device on the panel as one progress streaming job, a device failing its object list is skipped with its log line, and everything lands in one EDE 2.3 file with the formal header block, sanitised keynames, semicolons converted to commas, deterministic ordering by device, type and instance, and the byte order mark on the download. The BBB-205 promise is a suite now: the exported network file is fed straight back through the platform's own importer and read verbatim, the round trip the format exists for. One suite lesson owned: the byte order mark check first failed against a doubly escaped literal in the test itself while the endpoint had been correct all along, a reminder that receipts must be read from real bytes, which the corrected check now does. With this release BRW R1 through R5 are built: discovery, the ladder, the grid, writes, batch and export; R6 remains as receipts, polish and whatever the field walkthrough of campaign M surfaces. |
| 2026.08.8-4 | BRW R4: browser writes with verify, priority and relinquish (bridge driver, platform and console; no schema change; T88 green with nine checks, the landing write and the refused NULL both receipted live against the simulator). BRW-030: click any primitive value cell in the property grid, type, Enter writes with the tag hinted parse: null in any case writes a BACnet NULL, binary objects accept active, on and 1, inactive, off and 0, booleans accept true and false; arrays and constructed values stay read only and the tooltip says so; character strings with spaces are refused as a stated limit of this release rather than mangled. BRW-031: the priority arm on the grid header applies 1 to 16 with 8 as the default; after a write the property re-reads and the cell updates in place; a refusal restores the previous text and names the failure. BRW-032: the presentValue row carries the Priority button opening the sixteen slot panel with per slot Write and Relinquish, a NULL at that slot, refreshing both the array and the grid after any operation; a non commandable object reports its missing priority array honestly. BRW-033: every browser write lands one audit row through the standing browser operation audit, writes happen only on a deliberate Enter, and the browser's admin gate deliberately exceeds the specification's operator floor. Two stack truths earned their receipts and shaped the design: the public write is fire and forget, a nine millisecond ok on a write that never landed exposed it, so the driver awaits the underlying coroutine; and the stack logs rejections while returning quietly, so the verify read is the verdict: a NULL must verify as null and a value still sitting at its previous reading is a refused write, reported with the previous value for the console to restore, the verbatim device reply riding the bridge log. The commandable slot write and relinquish against real plant is FAT campaign H, exactly where the desktop tool's own untested write path waits too. |
| 2026.08.8-3 | BRW R3: the property grid and the three tier cascade (bridge driver and console, no schema change; T87 green with twelve checks, every tier forced by fakes and the live grid receipted off the simulator). BRW-020: selecting Props on any object reads everything through the desktop tool's cascade: ReadPropertyMultiple for all properties in one transaction; the device's own property list read one identifier at a time; the curated per type fallback for firmware predating the property list requirement, with families for analog, binary, multistate and device objects. Identity properties are prepended for tiers two and three, and a rejected identifier becomes a greyed failed row, never dropped. BRW-021: the full width grid presents Property, Value and Tag, identity first then alphabetical, with the object reference and a Re-read control above it, and the console states in words which tier the device answered on. The cascade is a module level coroutine pure of the stack, exactly the R2 pattern, so the suite forces the RPM answering device, the list serving device with a preserved error row, and the bare curated device, none of which one healthy simulator could show. Two honest translations stated: BAC0's ReadPropertyMultiple hands back a list of value and numeric identifier pairs with values already stringified, so tier one names resolve through the stack's own PropertyIdentifier table and tier one tags are a stated inference from the value's shape, while tiers two and three carry native tags from the decoded values; and the diagnosis that found this was a driver side shape probe whose one log line named the real structure in one run, evidence before theory as always. |
| 2026.08.8-2 | BRW R2: the object list ladder, gateway promotion, and real cancellation (bridge driver, platform, console; no schema change; T86 green with eleven checks, the ladder driven through every fake device the field knows plus a live end to end run). BRW-010: the object list read implements the desktop tool's five stage ladder with its exact timings as named constants: whole array at 3 then 6 seconds, length via array index 0 at 2, 4 and 8, per index reads at the stage 3 cadence with one longer retry then the stage 4 escalation, and the final 10 second sweep with half second pacing when at most fifty indices are missing; stage 3 rides paced single index reads because BAC0 exposes no ReadPropertyMultiple with per reference array indices, the batching optimisation noted in the parity register with robustness and timings identical. BRW-011: adaptive pacing, 25 milliseconds calm, 200 after any timeout, decaying as answers resume, with per request explicit timeouts carried natively by the stack. BRW-012: partial results return with the counted warning, never silent holes, and total silence produces the no reply diagnosis including the local machine sentence when the target is the bridge's own socket. BRW-013: cancellation is real, a new selection in the console cancels the running ladder through a dedicated command channel the driver checks between requests, and stale results are discarded by generation. BRW-005 completes: the stack's router information cache promotes devices that route replies to gateway with the dark green dot, and routed rows carry the router they arrived through, defensively coded and honestly logged when a stack layout hides the cache. The ladder itself is a module level coroutine pure of any BACnet stack, which is how the suite proves the healthy device, the array rejecting device, the partial answerer with its counted warning, the straggler rescued by the final sweep, the length rejection, the total silence, the mid run cancellation and the pacing curve, none of which a healthy simulator could imitate. One process lesson re-receipted: a run failed solely because the dev broker had died between container turns, exactly the standing rule about confirming broker_connected before publishing; the rule held, the receipt was the bridge log ending at stack initialisation with no command ever arriving. |
| 2026.08.8-1 | BRW R1: browser discovery parity (bridge driver and console, no schema change; T85 green with twelve checks, the wildcard probe and the campaign receipted live on the loopback rig; governed by the issued parity specification BRW 1.0, itself built from the desktop tool's functional specification BFM-FS-BBB-001). The field symptom that opened this work is diagnosed and fixed at its exact root: the console gave up on a Who-Is after thirty seconds while the scan campaign legitimately runs far longer, and never showed the progress the driver was already streaming, so a working scan read as a dead one; the poller now waits the campaign out and renders every sweep ordinal as it happens. BRW-001: the browser's campaign runs the desktop tool's four sweeps at 0, 2, 5 and 15 seconds, named constants at the top of the driver so field experience can retune without touching logic, while the platform's direct mode resolver keeps its own established schedule. BRW-003: after the sweeps a bounded, paced identification pass reads each device's name, vendor and model with progress, and a device that refuses stays listed by identifier alone. BRW-005: devices are deduplicated on address and instance, sorted numerically, grouped by network, and marked by transport with the established dot colours, blue native IP and light green routed, through a pure shaper the suite exercises directly; gateway promotion needs router visibility from the stack and lands with R2's driver work, stated here rather than pretended. BRW-004: adding a device by address alone now resolves the instance, a directed Who-Is first because every device must answer it, the wildcard 4194303 identity read as the fallback, with the two failure modes honestly distinguished: silence everywhere is the no reply diagnosis, an Error, Reject or Abort response is an alive address that refuses, named as such. One lab lesson recorded with its receipt: TimeoutError's own repr contains the substring error(, which briefly classified dead addresses as rejections until the exception was given its own clause; the fingerprint list now matches genuine BACnet response shapes only. Broadcast discovery of real devices remains FAT campaign M on the machine with real interfaces, exactly per the specification. |
| 2026.08.7-2 | Second walkthrough finding, resolved and pinned (console wording, a new slow suite, no engine change; T84 green with seven checks including a live browser operation through a project-less bridge). The report: a bridge could not be started without a project connected, yet starting a BACnet bridge purely to send a Who-Is through the BACnet browser must be possible. Diagnosis from evidence: the platform never blocked it, the start endpoint carries no project guard and the Start button always renders; the real blocker was the source-less driver exit fixed in 2026.08.7-1, whose symptom (the bridge dying instantly with exit 1) read exactly as cannot start without a project. What remained was a discoverability failure and a missing receipt, and both are closed here. The Bus Systems page now says it plainly: a BACnet bridge needs no project to run, started empty it idles as a discovery bridge and the browser sends Who-Is, browses devices and exports EDE files through it, and the projects column reads none (discovery bridge) instead of a bare none. The new T84 pins the whole promise end to end on the live stack: a bridge with zero projects and zero sources starts, survives BACnet stack initialisation, logs the documented idle line, and executes a browser Who-Is through the MQTT command channel returning ok with its timing, so the regression class can never return silently. Stated honestly: the Who-Is in the container answers empty because specific IP loopback sockets cannot receive subnet broadcasts, lab physics rather than product behaviour, and one directed read quirk observed against the loopback simulator is noted for field comparison; discovering real devices by broadcast is exactly the walkthrough item to retry on the machine with two real interfaces, where the 2026.08.7-1 socket rule now also allows both bridges to share the standard port across the two NICs. |
| 2026.08.7-1 | The FAT wave opens: two findings from the first walkthrough item, fixed and receipted together (app and bridge, no schema change; T83 green with five checks, the decisive receipt live). Finding one, reported from the field: creating several BACnet bridges demanded a distinct UDP port each, but a machine with two network interfaces legitimately runs two bridges on the same port, one per interface, because the real conflict is the socket, the local IP and port pair, never the port alone. The rule now accepts distinct explicit IPs on one port, refuses the same IP on one port, and refuses a bridge without an explicit IP against any same port bridge because a default bind conflicts with everything; the refusal names both bridges and the exact socket. Finding two surfaced while receipting finding one, exactly how a walkthrough should work: a bridge spawned with no sources at all exited immediately, while the platform has promised source-less idling as a discovery instrument since 2026.07.19-11; the idle branch only ever served sources that parsed to nothing, so every freshly created bridge with no project died on start. The early exit is gone and a truly source-less bridge now falls through to the documented idle. The decisive receipt runs both fixes at once: two BACnet bridges alive simultaneously on port 47809, one on 127.0.0.1 and one on 127.0.0.2, the second source-less and idling as documented with a clean log, then the standing configuration restored. One suite lesson recorded: the first T83 draft snapshotted the bridge list shallowly and could have restored a stripped configuration; the shipped suite restores from the full config objects and the standing config.json was verified intact from disk before anything else moved. |
| 2026.08.6-2 | Phase 6 build scope closes: the widget types, the offline receipt, and the v1 under v2 render proof (console, app and a new render harness, no schema change; T81 green with twelve checks, T82 green with nine, palette lint clean). DASH-003: six native widget types join dashboards, gauges on a small hand rolled SVG in the brand green, KPI tiles, status tiles that speak in palette (dark green ok, blue active, neutral grey stale, meaning carried by words and weight rather than forbidden colours), and pie, bar and line on the vendored Chart.js with history and aggregate data behind them; an Add widget form with the point reference datalist and per widget removal, every edit archiving a version, refs validated against the project's real points, options numeric with min below max, and removal shifting the surviving layout keys so a stored layout stays renderable, all receipted in T81 with every refusal by its exact message. DASH-004: the offline statement made concrete: the new render harness intercepts every network request of a full console boot and render and asserts all of them stayed on the local host, no CDN, no external anything. DASH-005 client half receipted: a v1 shaped dashboard, stored exactly as the shipped AI path stores them, driven through the real project selector, the real list, and the real click handler in jsdom, auto flows into grid items under v2 with its summary and chart beside the native widgets, static grid, zero unexpected errors; chart canvas pixels cannot exist in jsdom, the renderer's own guard degrades those cards to a stated fallback, and the pixel half belongs to FAT campaign L per the D8 model, together with the drag feel. One harness lesson recorded: the first interceptor draft broke the console's relative URL fetches and the evidence said so in the list host itself; the fix copies the ui_smoke pattern verbatim, resolve against the base, then fetch. With this release DASH-001 to 005 are all built and the 2026.08 series build scope is complete across six phases; what remains is the D8 closure: the consolidated FAT document, the walkthrough wave on the live installation, and the bug resolution releases it produces. |
| 2026.08.6-1 | Phase 6 opens: dashboards get their grid (console, app, one vendored library and a new gate tool, no schema change; T80 green with fourteen checks, endpoint receipts live). DASH-001: dashboards render on Gridstack.js, vendored locally like every other library, twelve columns, snap, no overlap, drag and resize behind an explicit Edit layout mode with Save; widgets are separated report style cards. DASH-002: the visual layer keeps the beez palette and Trebuchet MS, and a new gate tool, tools/dash_lint.py, flags any off palette colour in the dashboard styling by file, line and value, with a self test that plants an orange and proves the flag; the lint promptly caught this release's own first border tint, which was corrected to a neutral grey rather than widening the allowlist, exactly the point of the gate. DASH-005 server half: a saved layout rides inside the spec, every layout save archives the previous spec into the version history first, and restoring a pre layout version returns the v1 spec whole, receipted in T80 alongside the twelve column, integer, no overlap validation refusing every malformed layout by its exact message, so a stored layout is always renderable. The renderer feeds the shipped v1 render functions one element at a time inside grid items, and a spec without a layout, which is every AI dashboard saved so far, auto flows onto the grid with default sizes; that client side render compatibility takes its runtime receipt in the next release together with the widget types, and the drag and resize feel belongs to FAT campaign L per the D8 model. The smoke harness stands at its known two item baseline with the vendored grid library loaded. |
| 2026.08.5-4 | The strategy wizard and the doctrine library (app, console, template files and docs, no schema change; T78 green with twenty two checks, the wizard receipted live end to end, and the reconciliation document issued for CTRL-207 sign off). CTRL-207: the Control tab gains the Strategy wizard: pick a template, map its variables to real points with the project's references offered as you type, set a prefix, preview the exact plan, and create; everything arrives off, pre filled, source labelled and fully editable, the CTRL-206 rule extended to whole strategies. CTRL-209: the seed library ships four templates from the beez-fm doctrine, each carrying its doctrine section and its ASHRAE Guideline 36-2021 clause: the B2 chilled water reset with the protected 09:00 to 16:00 hold window riding the mechanism built for it one release earlier, the C3 supply air cascade that is the specification's own worked chain, the C5 zone humidity strategy with the deeper night drying target and an honest note that the full fan and valve choreography is engine mode work, and the B4 index AHU differential pressure loop whose learned setpoint is named as AI phase scope rather than pretended. Templates are declarative JSON in app/strategy_templates, validated by a pure loader that refuses missing source labels by design, expanded through placeholders for inputs, outputs, the setpoints a template itself creates, and the prefix; instantiation validates every mapped reference like a manual binding, wires cascades by name, refuses name collisions precisely, skips optional loops whose guard mapping is empty, and dry runs first. Receipted live: the wizard instantiated the B2 reset against the running stack and the worker computed and published its value within seconds, the flat clamp answer correct for the stand in source. docs/CONTROL_TEMPLATES.md is the reconciliation the specification demands: what shipped, what mechanism each strategy rides, and what waits on which capability (B8's wet bulb floor on psychrometric sources, B13 on the AI phase, D1's exact PID signature flagged open for the next library revision), nothing absorbed silently. One serious find owned plainly: release 5-3's dynamic setpoint model shadowed the pre existing SetpointIn class, and because the point write endpoint binds whichever definition stands last, plain setpoint writes would have failed validation from 5-3 until now; the model is renamed, the write path is receipted publishing a real value again, and T78 carries a permanent regression check so the shadow can never return unnoticed. The smoke harness stands at its known two item baseline. |
| 2026.08.5-3 | Dynamic setpoints: the doctrine becomes mechanism (app, worker, console and database, schema v37 with automatic migration receipted live; T79 green with twenty nine checks, both acceptance criteria receipted live end to end). CTRL-208: a dynamic setpoint is a reset curve on a source variable plus schedule windows and clamps, stored per project, evaluated by the control worker every cycle, and published as a real calculated point under ctl/ctl-setpoints, which means it lands in the tree, in Trends and in history like any other point, and any loop consumes it as an ordinary existing setpoint reference. The curve is piecewise linear with flat clamping beyond its end points, doctrine B2's chilled water reset shape verbatim. Schedule windows override it in list order with three modes: value pins a number, offset shifts the curve, and hold freezes the value computed at window entry, which is exactly the protected midday window of doctrine B2, so a reset never chases load spikes through the peak block; windows may wrap midnight, with the morning half belonging to the day after. A stale source falls back to the declared fallback or, without one, publishes nothing, which downstream loop fault handling treats like any stale setpoint. Sourceless single point curves with schedules give pure time programs. Receipted live in one chain: the v36 to v37 migration in the boot log, the worker computing 8.4167 from the curve against the live Modbus source value, exact to four decimals, published with retained meta and self provisioned as a point, a loop in test mode consuming it as its setpoint through the running engine, and a schedule window update moving the published value to its pinned number with the consuming loop following within seconds. Every refusal comes through with its exact message, ascending curve, name conflict, typed deletion confirmation. The console gains a Dynamic setpoints card in the Control tab with the list, a compact creation form and enable, disable and delete; the richer curve editing surface belongs to the wizard release. One evaluator defect was caught by its own suite before ever shipping: a schedule window starting at midnight parsed to minute zero and failed a truthiness check, fixed to test for None. Doctrine B8's wet bulb floor needs a computed psychrometric source and is named for the template library release, not silently absorbed here. |
| 2026.08.5-2 | Setpoint sources and the chains (console, app and provisioning, no schema change; T76 green with fourteen checks, T77 green with eleven, chain receipts live through the running engine). CTRL-204: the setpoint slot in the equipment editor is now the source block: a dropdown for existing, local and cascade, where existing keeps the droppable slot, local shows a value input that persists on Set, and cascade shows a loop picker across the whole project, so no value is ever invented on a loop's behalf. CTRL-205: chains persist and read clearly, with a feeds badge on any loop that others take their setpoint from, and both worked examples from the specification are receipted: the chains exist as data in T76, and the room temperature to supply air temperature chain also ran live through the worker with real Modbus field values, the downstream setpoint tracking the upstream output exactly, including through a setpoint change that moved the whole chain. CTRL-206: provisioned loops arrive pre-filled with their output, candidates and setpoint default, carry their origin badge, and are editable without loss, which now includes surviving a provisioning re-run. Three shipped defects were found under this work and fixed structurally. The loop list omitted every setpoint field, which the editor renders from; it now carries the source, value, ref, priority and out envelope. The edit protection on provisioned loops recognised only program source edits, so a scan or envelope edit silently reverted on the next provisioning run; an operator save now marks the loop edited regardless of the field, receipted in T77. And the best find of the release: the provisioning create path read last_insert_rowid on a fresh per thread connection, which returns zero, so every candidate it ever wrote was attached to loop zero and invisible, and only the refresh path attached them correctly; the insert now takes the row id from its own cursor and the orphan rows were swept. One honest note: while diagnosing, a manual meta restore overwrote the fan speed point's name, which the twin's role matching keys on; it was restored as Fan speed with its unit, a faithful reconstruction rather than a recovered original. The smoke harness stands at its known baseline of two pre-existing items, both A/B attributed to the shipped 2026.08.4-2 console. |
| 2026.08.5-1 | Phase 5 opens: the equipment editor (console and app, no schema change; T75 green with fourteen checks, endpoint receipts live). CTRL-201: the Control list now groups its loops by equipment, and Edit on an equipment header expands the editor inline directly below the row, no navigation; Close collapses it in place. CTRL-202: inside the editor the point pool lists every point of that equipment as chips carrying unit and kind, assignable two ways through one code path: pointer drag, which covers mouse and touch alike, and tap the chip then tap the slot as the accessible route. CTRL-203: each loop renders as a line with its three slots, setpoint, process value and controlled output, plus a config strip below for scan, priority and the out envelope. Every assignment is merge safe: the console reads the single loop, changes one binding, and puts the whole body back, so the ST source and everything else the editor does not touch survives, which T75 proves explicitly. Two shipped defects were found under this work and fixed structurally. The loop create endpoint used to wrap its insert in a blanket except that reported every failure as a name conflict, so an invalid objective produced a lying 409 instead of its precise 400; validation now runs before the guarded section and the except is narrowed to the real uniqueness violation, receipted live with the vocabulary message coming through. And the loop API did not carry the out envelope at all: out_min, out_max and slew_per_min existed in the schema but not in the model, insert or update, so no editor could ever have persisted them; all three are now carried through end to end and round-trip in T75. Assignment by pointer drag is desk verified in this environment (jsdom drives no pointers) and takes its field receipt from the first touch test on a live install. The smoke harness carries one new pre-existing item beside the known Graphics tree difference: a jsdom navigation error that an A/B against the shipped 2026.08.4-2 console reproduces identically, so it belongs to the harness backlog, not to this release. |
| 2026.08.4-2 | Relations closes: the graph drives control and survives everything (app, console and importer, no schema change; T53 green with fourteen checks, receipts live end to end). REL-003: the cascade proposer now consumes the typed feed relations, feeds, supplies air, supplies chilled water and supplies hot water, one source in the relations store, so a chilled water edge drawn on the canvas from a chiller to an MAU drives the CTRL-163 cascade pair proposal exactly as the plain feeds edge always did; power and shares plant stay honestly outside the control topology, and two fed zones are still named ambiguous, never guessed. A Preview cascade effect button in the Relations tab runs the provisioning preview against the current topology and reports the pair and ambiguity counts. REL-004: every change to the graph, an edge confirmed or dismissed, a layout saved, snapshots edges and positions into the project's own relations-history folder, newest thirty kept; a Graph history card lists the versions and Restore replaces the current graph after the typed project slug, with the pre restore state snapshotted first so a restore is always reversible. The graph rides the bundle: GET /api/relations/export emits the FS Part 2.4 relationships shape, an Export for bundle button downloads it, schema two bundles carry a relationships block through the normalizer, and the importer writes the edges with author bundle, warning on odd entries (a self edge, an unknown type) rather than dying, and idempotent on re import. Receipted live: an edge creating a version; the restore refused without the typed slug and reverting the graph exactly with it; the export shape; a bundle with two good edges and one deliberate self edge importing with the edges written as bundle, the warning naming the exact entry, and the re import idempotent beside points_changed zero; and the cascade preview endpoint answering an honest zero on the development site, whose equipment matches no fresh drafts, while T53 proves the typed edge mechanism headless with a stubbed store. With this release the Phase 4 requirement set REL-000 to REL-004 is build complete; the canvas walkthrough and the cascade acceptance on a template matched site belong to the D8 FAT wave. |
| 2026.08.4-1 | Phase 4 opens: Relations gets its home and its canvas (console, app and one vendored library, no schema change; T52 green with sixteen checks, endpoint receipts live). REL-000, finally in the mainline: the Proposed relationships review leaves the alarm page for its own Admin, Relations sub tab between Alarms and Audit, with its own project selector and refresh, and the alarms live path stops loading the mining card; the same two endpoints serve the new home, decided pairs still stop being proposed, and nothing changes in the schema or the API. REL-001 and REL-002: the relations canvas, rendered with Drawflow 0.0.59 (MIT, vendored at web/static/vendor, no build step, no CDN), shows every equipment of the selected project as a draggable branded node and every confirmed relationship as a typed, colour coded edge. Dragging between ports confirms a relationship of the selected type through the existing confirm endpoint; deleting an edge dismisses the pair with the exact semantics the proposal review established, drawn no more and proposed no more. The edge vocabulary is one source for both sides (app/relations_store.py through /api/relations/meta): feeds, supplies air, supplies chilled water, supplies hot water, supplies power and shares plant, coloured inside the palette discipline, the blue for air, the greens for chilled water and power, neutral grays where red or orange would be conventional but are forbidden. Node positions persist per project in the project's own folder (relations-layout.json via GET and PUT /api/relations/layout with strict validation), so a project backup carries its drawing; full versioned history and the bundle export are REL-004, next release. Receipted live: the vocabulary with its colours on the endpoint; a typed edge accepted, listed with its actor, and painted; the refusal naming the whole vocabulary; the layout round trip persisting into the project folder with rounding applied and the refusal naming the exact node and field; the dismissal emptying the list; and the alarms page verified free of the mining call. The canvas drag interactions themselves are desk verified as all pure UI is, and the first walkthrough on the live installation is their receipt. |
| 2026.08.3-2 | The third bus: API-001, the generic REST/JSON connector, on the Python standard library alone, so it ships with zero dependency, not even an optional one (bridge, app and registry, no schema change; T51 green with twenty nine checks, receipts live end to end). A REST source is a beez_rest_map (schema one): HTTP JSON endpoints, a declarative dot and bracket path per point ("data.zones[2].t"), auth as none, bearer or basic exactly per the stated 2026.08 non goal, per point scale and the BUS-006 threshold, and an optional write block with a templated body where the value placeholder keeps numbers numeric. The driver honours the full platform contract established twice before: retained values and meta, writes on /set with an HTTP post or put followed by a read back so the published value is what the endpoint actually reports, the transport arbiter with poll provenance and url hash path object references feeding the same health file, endpoint, Transport view and survey export, status on the internal channel, the pulse file, patient MQTT startup, and dead endpoint backoff; points sharing a url are fetched once per cycle. Bundle schema two accepts rest sources as first class beside modbus (the importer validates through the driver's own loader, creates the points and records bus_sources), PUT projects/(id)/rest attaches them, and the bridge merges its spawn sources from configuration and project rows like its siblings. One structural fix from a live symptom: the UDP port uniqueness rule was BACnet era and collided two portless bridges, so it now binds only buses that actually own a local UDP port. Receipted live in one chain: the registry resolving three drivers with opcua alone in the planned catalogue per decision D6; a rest only schema two bundle importing with five points; the attach; the bridge polling purely from the project row with the request counter climbing by exactly one per cycle, proving one HTTP fetch serves all five points; array indexing and string values on the wire; the platform setpoint write posting HTTP 200 with read back 24.5, audited as actor admin, bus rest; the survey export covering the rest bus with url hash path identities; and the re-import at changed zero with bus sources unchanged. With this, every FS 2026.08 bus is shipped and the new Phase 3 build set is complete: PATH-003 and FAT campaign J now execute in the pathway and the FAT wave per decision D8. |
| 2026.08.3-1 | Phase 3 opens (the survey round trip, FS Rev E numbering): the survey export generalises across the shipped buses and the importer learns to prove its own idempotence (app and console, no schema change; T50 green with twenty one checks, receipts live on both buses). SRV-002 finalisation: GET /api/survey builds the beez_bus_survey (schema one, FS Part 2.3) for any project on any shipped bus, platform side, joined from the points table and the serving bridge's health snapshot; per point findings are honestly derived from the arbiter (dead when no value was ever seen or the last one is older than three windows with both numbers named, cov_capable from a live verified subscription, cov_silent from an arbiter demotion, provenance on every live point), devices group by the true per bus identity (the BACnet device instance, the Modbus unit id parsed from the object reference), and the point key is the arbiter's object reference with the riza1 path always recorded beside it. A Download survey JSON button joins the Transport view. PATH-003 foundation: the importer now counts matched points whose authoritative fields actually differ, mirroring the upsert exactly, and reports points_changed and bus_sources_changed, so FAT campaign J has its measurable zero. Housekeeping per decisions D6 and D7: the planned bus catalogue now names OPC UA as the 2026.09 series and the REST connector as Phase 3 of this one. Receipted live: the catalogue strings; the full changed detection chain, a fresh import at changed zero, an identical re-import at changed zero with bus sources unchanged, a single renamed point detected as exactly one change, and the SAFRAN thirty nine point re-import at changed zero on real data; the Modbus survey against the live simulator with object references and clean findings; and the BACnet survey against the dead development target with all seventy five points honestly flagged dead under real object identities, which is precisely what a site survey must say about an unreachable device. One test defect was caught and owned during the session: the determinism check compared builds with different arguments and was fixed to compare like with like. |
| 2026.08.2-4 | Phase 2 closes: change significance and datasheet ingestion, plus the pathway amendments that carry the round trip (bridge, app, tools and docs, configuration only, no schema change; T49 green with sixteen checks, receipts live). BUS-006, the Modbus slice of the covIncrement analogue: the transport arbiter gains a bus generic publish gate; threshold zero keeps the shipped behaviour of publishing every poll, a positive threshold, in engineering units after scaling, suppresses smaller moves and counts them in the health statistics, the comparison runs against the last published value rather than the last seen one, any non numeric change publishes, and a keepalive floor of thirty seconds or three intervals republishes an unchanged value so a quiet point never looks dead. The threshold lives per point in the map, per source or per bridge in configuration, and travels as the fourth field of the modbus source string; the semantics are flagged to Didier as the miniBMS analogue of the fleet covIncrement philosophy. MBS-007: vendor datasheets ingest through tools/modbus_datasheet.py, a CSV convention where only fc and register are mandatory per row, everything else defaulted, and the output must pass the bridge's own map validator; the FS acceptance is proven exactly, a datasheet of the same registers yields the identical point identity set as a live scan. Two paste ready amendments ship beside this release: the pathway project amendment (survey JSON and datasheet inputs, the schema 2 bundle it must emit, the guarantees and every refusal miniBMS enforces) and the development project amendment (series baseline, the re-based suite register T42 to T49, binding conventions, the open field gated register), superseding the FS Part 4 drafts with what actually shipped. Receipted live: on one wire, a threshold zero point published every cycle while its thresholded neighbour published once and then only at the keepalive, the retained timestamp advancing by exactly thirty seconds on an unchanged value, with the suppressed counter climbing on the health endpoint. With this, every Phase 2 requirement is shipped or explicitly registered: MBS-006 rides the 2026.09 RTU decision, PATH-002 and PATH-003 execute in the pathway project against the amendment, and the field gated register (real gateway sweep, cross bus write receipts, fleet soak) waits for live sites. Phase 3 opens with OPC UA when called. |
| 2026.08.2-3 | The round trip closes: a survey becomes an importable, pollable, writable project (app and database, schema v36 with automatic migration; T48 green, receipts live end to end). PATH-001: bundle.json learns the nested schema 2 from the FS, multi bus sources beside the classic content, and every v1 bundle imports byte for byte as before, receipted by re-importing the SAFRAN bundle unchanged, thirty nine parsed, zero created, thirty nine refreshed. A modbus source names its map, target and interval; the importer validates the map through the exact loader the bridge polls with, creates the points with their meta and deterministic identities in the description, stores the map under the project's source folder, and records a bus_sources block on the project row; unknown bus types (opcua, lon, rest) warn instead of refusing because the schema is additive by design. BUS-004: schema v36 adds bus_sources to projects and bus to the commands audit, migrating automatically on first boot, receipted live on the real development database, thirty five to thirty six with both columns present. A new PUT projects/(id)/modbus connects the imported sources to a modbus-tcp bridge, the exact sibling of the BACnet connect, and the bridge now merges its spawn sources from the bridge configuration and from every project row it serves, so a bridge with zero configuration of its own polls purely from what was imported. BUS-005 first receipt: every field write already travels the platform's single write path, and its audit now names the bus; a console write to the real coil flipped it on the wire, was read back by the driver, and landed in the audit as actor admin, value one, previous zero, bus modbus-tcp, the identical record shape every bus shares. Receipted live in one unbroken chain: migration, a modbus only schema 2 bundle importing with three points, the attach, the bridge polling purely from the project row, decoded values on the riza1 wire, the Transport view joining an imported modbus project for the first time, the audited coil write, and the v1 re-import; the receipt fixture was cascade deleted afterwards and the smoke gate returned to its known baseline. Remaining in Phase 2 and stated so: MBS-007 datasheet ingestion rides the pathway project, and PATH-002/003 close with the pathway amendment. |
| 2026.08.2-2 | The Modbus survey lands: point a bridge at any TCP target and learn what lives there, no project required (bridge, app and console, no schema change; T47 green with twenty checks, wire receipts live). MBS-005: Sweep enumerates unit ids behind a gateway; any answer means a device, gateway exceptions ten and eleven honestly mean nobody home, and FC43/14 Read Device Identification is captured where the device supports it (the dev simulator answers exception four for unknown units, a pymodbus server artefact stated here so nobody chases it; real gateway discrimination stays field gated per the FS). MBS-002/003: Scan reads the conventional spaces in blocks of up to 125 registers, the hard PDU ceiling, subdividing on exception codes two, three and four so per block exceptions narrow rather than abort, abandoning an unsupported function after one probe, and counting every request against a budget so a scan can never flood a gateway; the worst case cost, one request per absent register inside exception regions, is documented and is exactly why the budget is mandatory. MBS-004 first slice: every discovered register pair is tested as float32 across all four word orders inside a plausibility window; a single plausible order becomes a suggestion at confidence 0.9, ambiguity is flagged at 0.3 with every candidate listed and nothing guessed, printable runs are flagged as string candidates, and scale detection is honestly deferred to the survey soak because it needs two samples over time. SRV-001: a source-less modbus bridge now idles as a survey instrument instead of exiting. SRV-002: the scan exports the beez_bus_survey JSON, schema one exactly per the FS Part 2.3 draft, deterministic apart from its timestamp, plus a draft beez_modbus_map that must and does pass the bridge's own validator unchanged, both downloadable from the new Modbus sub tab (BUSUI-004) which also shows per space ranges, per register suggestions with confidences inline, and the 125 register rationale in plain words. Receipted live against the simulator: the sweep with its honest per unit notes, the scan isolating exactly holding 0 to 3 and input 0 to 1 by subdivision within 569 of 900 budgeted requests, the float32 CDAB suggestion recovering 21.5 at 0.9 on the real wire, and the round trip of the generated draft map through load_map; the console surface itself is desk verified as all pure UI is, and the first field gateway sweep will be its live receipt. |
| 2026.08.2-1 | Phase 2 opens: Modbus TCP becomes the second bus (bridge, app, docs; configuration only, no schema change; T46 green with twenty six checks, wire receipts live end to end against a real pymodbus server). MBS-001: a new driver, bridge/modbus_bridge.py, registers behind the BUS-001 registry, which now proves its whole reason to exist: the platform spawns a second protocol through the same endpoints without the BACnet path changing by one character. The driver honours the full platform contract: retained values and meta on the standard riza1 topics, writes on /set with read back so the published value is what the device actually holds, per source status on the internal channel, the per point transport arbiter with honest POLLING_DEFAULT states and poll provenance feeding the same health file, endpoint and Transport view, plus the pulse file. Reads cover FC1 to FC4 with int16, uint16, int32, uint32 and float32 across all four word orders and scaling, decoded by a pure codec (bridge/modbus_codec.py); writes cover coils and registers. BUS-003: points come from a versioned beez_modbus_map JSON (schema 1, bridge/modbus_map.py) with a strict validator naming its exact complaint and the deterministic identity unit id, function code, register, datatype; the map format, the identity rule and the three Didier flag items are documented in docs/MODBUS.md, and nothing on the wire changes for any existing device. pymodbus 3.14 rides as an optional dependency on the rdflib pattern; absent, the driver exits with the one line that says what to install. Sources live on the bridge configuration for now; project level attachment arrives with the Phase 2 schema work, and Modbus RTU stays in 2026.09 per decision D3. Receipted live: bridge configured and started through the endpoints with the map project derived into the bridges table, float32 CDAB decoding to exactly 21.5 on the wire beside the scaled int16, the fc4 and both bit reads, the /set write flipping the real coil with its read back logged, health and statistics on the endpoint, and the full outage cycle run twice for honesty: the first recovery attempt was stopped inside the backoff window and said so, the rerun proved reads resuming automatically after the target returned, fifteen to seventy successful reads with failures frozen. Also hardened from a live symptom this session: the driver now retries the MQTT broker patiently at startup instead of dying on the first refusal. |
| 2026.08.1-4 | Phase 1 closes its console scope: the MQTT topic inspector, and the object references that connect the logical view to the physical browser (app and console, no schema change; T45 green, receipts live). BUSUI-006: a new MQTT sub tab in Bus Systems streams every topic the platform hears, newest first, with the retained flag exactly as the broker sent it, a filter that matches topic and payload, pause and resume, and a second card listing the latest meta payload per topic as received from the broker, the retained set replayed at connect plus every fresh publish since, bounded to the newest six thousand. The tap is two bounded writes per message on the existing traffic ring pattern, always on, never raising, and both endpoints carry the platform's standard tenant scoping: a scoped admin sees only their own projects' topics and never the internal namespace, a superadmin sees everything. BUSUI-003 second slice: the Transport view now shows each point's BACnet object reference (type and instance from the source EDE), plus the source target address and device instance, and a new In browser action jumps to the physical browser with bridge, address and device instance prefilled, the first direction of selection carry over; the reverse direction, physical to logical, waits for a later release and is stated so. Receipted live: fresh publishes appearing on the stream with the retain flag clear and the filter matching, the retained replay branch proven properly by restarting the platform against the live broker and receiving the stored meta flagged retained, the scoping rule and both bounded stores proven headless across sixteen T45 checks, and the object references receipted on the real SAFRAN project where a dead target also proved the fault counters again: two poll cycles, seventy eight failed reads, two timeouts, all visible on the transport endpoint. One dev environment note recorded for the log: the development broker keeps no persistence, so its retained store empties on broker restart; on the field installs mosquitto persistence keeps retained meta across restarts, which is exactly what the replay receipt exercised. |
| 2026.08.1-3 | The Bus Systems tab learns to answer the field's first three questions: how does this point travel, what does it read right now, and does COV actually work here (app, bridge and console, no schema change; T44 green, receipts live). BUSUI-003, first slice: a new Transport sub tab shows one project's points grouped by site and equipment, each with its live state chip in the five FS colours, its provenance (cov or poll) and the age of its last update, joined server side by GET /api/transport from the serving bridge's health snapshot with the same tenant scoping as every project read, and honest notes when a project has no bridge or the bridge has not written a snapshot yet. BUSUI-007: two troubleshooting affordances ride the existing browser job channel as new bridge operations. Read now fetches one fresh presentValue through the bridge on demand, publishes it, and reports the value with its round trip time; Subscribe test asks the controller for a sixty second COV subscription on one point, waits up to ten seconds for the first notification, cancels cleanly, and reports accepted, notified and the first notification latency, or the refusal, in plain words. Directed Who-Is already lives in the BACnet browser sub tab and stays there. The arbiter gains per source error and timeout statistics (notifications, reads ok and failed, timeouts, subscribe failures, silent and audit demotions), fed from every read path, carried in the health snapshot, the status payload and the Transport header line. Receipted live against the real BACnet server: the transport join with verified states and mixed cov and poll provenance, read now returning 42.0 in 8 ms, subscribe test reporting its first notification in 7 ms, the honest error for an unknown point, and the FS acceptance for the counters proven by an induced fault: the server was killed, the next read timed out, and reads failed and timeouts both incremented on the endpoint. Selection persistence between the physical browser and the logical view waits for the device to point mapping in a later Phase 1 release, as does the auto refresh of the view, which today refreshes on demand and shortly after each action. |
| 2026.08.1-2 | Every BACnet point now knows how it travels: the per point transport arbiter lands (bridge, app and console, configuration only, no schema change; T43 green, wire receipts live against a real BACnet server). BNH-001: each point carries one of five states, COV_SUBSCRIBED, COV_VERIFIED, COV_SILENT, POLLING_FALLBACK, POLLING_DEFAULT, every transition printed with its timestamp in the bridge log, and the invariant holds that nobody sits in COV_SUBSCRIBED past the verification window. BNH-002: after subscribe the arbiter arms a liveness window and the heartbeat rotates one confirming read across the verified points each cycle; silence past the window, or an audit read disagreeing with the last notified value outside a short notification grace, demotes the point honestly. BNH-003: silent and refused points fall back to polling at the bridge default interval with no operator action and re-attempt COV on an exponential backoff capped at an hour; a notification heals them instantly. The state machine also closed a real field gap it made visible: in COV transport the bounded remainder outside the 300 point shortlist previously updated never, not at the heartbeat cadence the F-009 note promised; it now genuinely polls at that cadence in the new side poll task. BNH-004: provenance, last update source and timestamp per point, recorded on every publish, written atomically to data/bridge-health-(name).json every ten seconds beside the freeze detector pulse, served read only at /api/bridges/(name)/health, added to the internal bridge status payload, and rendered by the new Health action in the Admin bridges table. BNH-006: subscriptions renew proactively at eighty percent of lifetime, not only on lapse, and lifetime plus confirmed versus unconfirmed mode are real per bridge configuration fields carried to the spawn line. The riza1 value payload is untouched; provenance lives on internal channels only, so no fleet convention changes and nothing for Didier to countersign. Receipted live: subscribe accepted to verified transitions against a real bacpypes3 server, cov and poll provenance side by side on the health endpoint, values streaming on the wire, the honest no snapshot note, and the config round trip; the silent and audit demotion ladder is proven headless in T43, with live controller receipts field gated per the FS as always. |
| 2026.08.1-1 | The 2026.08 series opens: phase numbering and the multi bus foundation (app and console, configuration only, no schema change; receipts green live). The version scheme turns to 2026.08.x-y as decided in FS 2026.08 (D4); the updater needed no change because update.ps1 already writes every transition to update.log as old string to new string read verbatim from the two VERSION.txt files, desk verified line by line, and the Windows run remains the field receipt. BUS-002: every bridge line now carries a bus_type, defaulted to bacnet on read so configurations written before the field existed gain it without their file being rewritten, normalized and validated on save, surfaced by the bridges API and shown as its own column in the Admin bridges table. BUS-001: a bus driver registry (app/bus_registry.py) resolves the launch recipe from the bus_type; bacnet resolves to the exact V7 spawn line, character identical by construction, and an unknown type is refused with an honest 400 naming what is registered (bacnet) and what is merely planned (Modbus TCP in Phase 2, OPC UA in Phase 3, the REST connector in Phase 4), so the console can tell the truth about the roadmap instead of pretending. Suite T42 covers the registry; the config round trip growing the default, the refusal, and start and stop through the endpoints with the spawned process writing its own banner are receipted live. Phase 1 continues with the per point transport state machine (BNH) and the Bus Systems troubleshooting affordances. |
| 2026.07.20-93 | The empty answer, the unparsed reply, and the starved console: three field findings, three structural fixes (app, no schema change; receipts green live, seven suites green). The Explain failure was finally caught by our own new logging, which named it exactly: DeepSeek returned an empty answer. The cause is reasoning mode: a thinking model spends tokens reasoning BEFORE it writes, and against a small budget the reasoning consumed the whole allowance, leaving no text at all. One shot narrations (explanations, dashboard answers) now switch thinking off explicitly and carry a raised floor of three thousand tokens, and if a provider still returns only reasoning, that reasoning is shown with an honest note rather than nothing. The Diagnose reply that arrived as raw JSON was a parser giving up too early: the extractor now recovers the first balanced JSON object from anywhere in a reply, string aware so braces inside quoted text cannot fool it, and logs when it had to recover or failed; the run itself was excellent evidence for the caching work, 1.8 million input tokens at 98 percent served from cache. The console slowness had a real structural cause, not the AI: every database read in the platform serialised on ONE global connection lock, so during a 141 second diagnosis every console poll queued behind the analysis's heavy history queries, which is exactly the twenty to sixty second latencies the field saw. Reads now use per thread connections, which is what SQLite's WAL mode exists for, while writes keep the single locked path so write serialisation is unchanged, with a fallback to the proven path on any reader error. Receipted: with four concurrent heavy readers hammering the point table, console status latency stayed at 36 ms worst case. |
| 2026.07.20-92 | The wire format restriction is gone: every provider can run research and automatic diagnosis (app and console, no schema change; translation receipts and four suites green live). The honest answer to whether the Anthropic format restriction was causing an issue was yes: it locked the two most valuable features, the research loop and automatic diagnosis, away from OpenRouter, Gemini, OpenAI and any Llama host, and OpenRouter in particular is where inexpensive capable models live. The barrier was never capability, both formats do tool calling, only spelling: Anthropic carries tool calls as content blocks, OpenAI as assistant tool_calls with role tool replies. This release adds a translation shim of three pure functions (tool definitions, conversation to OpenAI shape, reply back to the shape the loop already understands, including the usage split so cached tokens still report honestly), so ONE loop now serves every provider, choosing its endpoint and headers from the line's wire format. The diagnosis module selector accordingly offers every keyed provider, and the primary picker no longer filters by format anywhere. Receipted: the translation round trips tool calls, tool results and usage in both directions; with OpenRouter as primary the loop now ATTEMPTS OpenRouter (the development container's network policy blocks the host, which is itself the proof that the call was made rather than refused by format). Two test suites asserted the old restriction and were rewritten to the new contract rather than left asserting removed behaviour. Live cross format tool loops remain FAT campaign D's to certify on real keys. |
| 2026.07.20-91 | One place decides the model, and the Explain panel stays open (app and console, no schema change; receipts green live). Three field reports, three fixes. The Explain panel vanished after a few seconds because the live alarm list re rendered underneath it every refresh cycle and rebuilt the rows; explanations now live in console state keyed by alarm, and every render restores any open panel with its content, so the panel stays until it is clicked shut. The model selection had genuinely become ambiguous, and the cause was archaeology: the pre registry AI block (Model in use, its own model list, Use this model, Clear key) was still on the page beneath the new table, offering a second contradictory way to set a default; it is now deleted, markup and handlers alike. In its place sits ONE control, Answers come from, listing challenge mode plus every provider that has a key, showing each provider's chosen model beside its name; picking a provider makes it answer everything and turns challenge off, picking challenge mode lets every ticked provider answer with the first synthesising, and the per line Default radio column is gone because that decision now has exactly one home. Receipted: the selection round trips and drives the same picker every AI path uses; the legacy controls are verified absent from the page. |
| 2026.07.20-90 | Explain expands under its alarm, and the AI layer becomes observable end to end (app and console, no schema change; receipts green live). The field console dump diagnosed itself once read properly: the POST returned in 1153 ms with a job id while the page, still serving cached -88 assets against a -89 server, expected a finished explanation and rendered nothing, which is exactly the empty Explain panel reported. Three answers ship together. Explain now expands INLINE directly beneath the alarm it belongs to, a bordered panel with the evidence bundle folded into it, clicking Explain again collapses it, and the old distant card remains as a fallback. Observability is now total: a rolling AI call log records every routing decision (which provider was chosen and why), every call start with provider, model and prompt size, and every outcome with elapsed milliseconds, exposed at Admin, AI for administrators and echoed to the server console, with secrets structurally excluded (receipted); the browser console gains matching lines at every step, and the panel itself names the provider it is waiting on, so the answer to did Explain use my default is now visible in three places at once. Version mismatch stops being silent: when the console and server versions differ, a red banner says so and names both versions, because a caching proxy in front of the platform can defeat the healing reload; that banner is the sentence that would have saved this whole exchange. Update, then hard refresh once. |
| 2026.07.20-89 | Explain survives any proxy, and DeepSeek's catalogue found its real address (app and console, no schema change). The -88 console dump, read again with fresh eyes, acquitted every suspect previously blamed: the sixty second POST answered with Proxy error and HTTP 200 was the FRONT DOOR tunnel hitting its upstream timeout while the platform was still legitimately working, because a thinking model routinely needs more than a minute. The structural cure is the one the research loop has always used: the explain endpoint now returns a job id immediately and the browser polls every 2.5 seconds with a live elapsed counter, up to five minutes, so no proxy budget anywhere in the chain can ever sit between the operator and the answer; the job store is per user, garbage collected, and failures carry the provider's named error. The 502 on List models for DeepSeek was a separate discovery with the same lesson about wrong addresses: DeepSeek serves messages on its Anthropic compatible surface but its model catalogue ONLY on the OpenAI surface, and asking the wrong surface returns the gateway's own error page; the catalogue fetch for any DeepSeek line now goes to the right address. Receipted live: the job endpoint answers honestly for unknown jobs and the catalogue error names DeepSeek rather than echoing a gateway page; the start and poll happy path is desk verified (the development database carries no live alarm to explain), and the field run is its receipt. Update, hard refresh once, press Explain, and watch the counter tick instead of the tunnel lying. |
| 2026.07.20-88 | Three bugs from one field console dump, all fixed (app and console, no schema change; receipts green live). First: the AI tab's model picker still called the pre registry endpoint that only knew the legacy Anthropic key, so with that key dead the picker failed regardless of the chosen default; the endpoint now lists the PRIMARY provider's live catalogue and names that provider in its errors, falling back to the legacy path only on pre registry installs. Second: the -80 markup rework removed the ai-provider-msg element while eleven handlers kept writing to it, producing a stream of null reference errors on every AI card interaction; the element is back, once. Third, the subtle one: a proxy or captive gateway in the server's outbound path answered a provider call with an HTML error page carrying HTTP 200 (the literal body was Proxy error), which the response parser swallowed into a confusing failure; every provider facing response is now parsed through one guard that turns a non JSON body into a named, honest error stating the provider, the real status, the body head, and the likely proxy interception, applied to both wire formats, the model catalogue fetches and the research loop alike. Field note that matters as much as the fixes: the reporting machine was still running -85, so neither the -86 table nor the -87 default routing fix was on it; update first, then retest, and the 47 to 60 second explain latencies observed are the outbound path's, not the platform's. |
| 2026.07.20-87 | The Explain button honours the platform default (app, no schema change; routing receipts green live, from a live field bug: the default was DeepSeek and Explain still failed on Anthropic's empty credit balance). The flaw was one line old and structural: plain_ask, which powers alarm explanations and dashboard conversations, predated the -85 default and still answered with the first keyed line by seeding order, Anthropic, regardless of the choice; the challenge synthesizer had the same bias. Both now route through the chosen default, with seeding order deciding only when no default was picked. The receipt is the elegant kind: with fake keys on both lines and the default set to DeepSeek, the attempt fails naming DeepSeek (unreachable from the development container by network policy); flip the default to Anthropic and the failure names Anthropic (its 401 on the fake key), so the provider named in each failure IS the proof of where the call went. Deliberately NOT added: silent failover to the next provider when the default fails, because a quiet switch would have masked exactly the billing problem that surfaced this bug; the error names the failing provider instead, and the operator decides. |
| 2026.07.20-86 | The AI card grows up (console only, no schema change, desk verified as all pure UI work is). The provider lines become a real aligned table: Provider, API key, Model, Challenge, Default, Status, with every column sized and every row breathing the same rhythm. The controls join 2026: challenge participation is a sliding toggle switch in brand green, the platform default is a clean filled radio, keyed state is a rounded status chip (green when configured, grey when waiting), buttons share one rounded style with a green hover fill, and inputs and selects share one height, one border radius and a green focus ring. Automatic diagnosis moves into its own quiet panel beneath the table with the same switch language. All of it ships as a shared component family in the stylesheet (ai-table, chip, btn-2026, bz-switch, bz-radio), so the next card that wants to look like 2026 inherits it with class names instead of copies, per the structural fixes over one offs rule. Every data hook kept its name, so the -85 handlers run unchanged. |
| 2026.07.20-85 | The chosen default, the FDD switch with its module, and complete AI audit coverage (app, no schema change; T41 receipts green live). Anthropic loses its remaining privilege: a default radio on each keyed line makes any provider the platform default, OpenRouter, DeepSeek, whichever, and the picker honours the choice over seeding order, falling back sensibly (an OpenAI format default still lets the research loop find an Anthropic format line for itself). Automatic diagnosis gets its own block on the AI card: a master switch that silences every per rule and per project opt in at once, and a module selector choosing WHICH Anthropic format line runs diagnoses, refusing an OpenAI format choice with the honest wire format explanation; the FDD runner passes the chosen module into the research loop explicitly. Audit coverage was verified call site by call site and is now total: research jobs (ai.analyze), dashboard questions (ai.dashboard_ask), alarm explanations (alarm.explain), automatic diagnoses (fdd.auto at queue, skip and completion) were already recorded; the two control review analysis sites were the only silent callers and now write ai.review_run entries, so every single model inquiry the platform makes, human or automatic, appears in the audit trail. Session note per the receipts culture: a regex driven audit insertion corrupted a try statement and was caught by py_compile before anything shipped; the audits were then placed against each site's real variables with scope verified. |
| 2026.07.20-84 | Prompt caching and the honest cost line (app, no schema change; from a live billing incident: a fresh key passed its ten dollar limit on what looked like one prompt). Two findings, both recorded. First, the arithmetic: at a large site the research loop's system prompt embeds the whole point inventory, roughly four hundred thousand tokens, and the tool loop resent it at full price every round, up to twelve times, so a single research question could legitimately cost ten dollars or more; the operator's own provider console log showed exactly this shape. Second, the platform does contain automatic diagnosis: an alarm raise can trigger the full research loop through the opt in auto FDD policy (per alarm rule, or per project for built in checks), flood limited by a cooldown per equipment and a two in flight cap, and every such run is recorded in the fdd_runs table with its trigger, so the fdd_runs table ordered by created_ts answers definitively whether alarms spent the money. The fix benefits both paths equally: the stable prefix (system prompt and tool definitions) now carries cache_control on the Anthropic wire, so rounds two onward and any run within the cache window read the huge context at roughly a tenth of the price instead of repaying it; DeepSeek's Anthropic endpoint ignores the marker harmlessly because its disk cache is automatic. And every analysis now ends with an honest cost line: total input tokens, the percentage served from cache, and output tokens, attached to the result and printed in the summary, so no analysis cost is ever a surprise again. Receipted: the pure builder marks the prefix and leaves the first tool untouched; live cache rates are FAT campaign D's to certify. |
| 2026.07.20-83 | Provider independence: the AI module runs on whichever line has a key, Anthropic no longer required (app, no schema change; T40 receipts green live, T38 and T39 green on the final tree). Every AI entry point now resolves its provider from the registry: the first enabled line with a key is the primary, so a DeepSeek only system, or a Gemini only one, activates the AI features with the model selected on that line, no Anthropic key anywhere. The honest boundary, stated in the product itself: the research tool loop speaks the Anthropic wire format, so it runs fully on the Anthropic or DeepSeek lines (or any anthropic format provider); a system keyed only with OpenAI format lines (Gemini, OpenAI, Llama, OpenRouter) gets explanations, dashboard conversations, lessons and challenge mode, and the research loop declines with a message that says exactly this instead of a misleading missing key error. The alarm explainer was rewired through the same registry path (it also gains challenge mode for free), and the research second opinion now comes from the first ticked challenger that is not the primary, whoever that is, instead of hardcoding DeepSeek. Receipted live: DeepSeek only primary carries everything including the loop; Gemini only carries the chat features and the loop refuses with the honest message. Session note recorded per the receipts culture: a splice during this work twice removed neighbouring code (an entire function block, then a constant) because the cut boundary was wrong; both losses were caught by function inventory and AST verification against the shipped baseline, the file was restored from the -82 package each time, and an AST completeness check now accompanies any body replacement in this module. |
| 2026.07.20-82 | The reload loop breaker and the exposure hardening pass (app and console, no schema change; from a live field incident: a localhost origin refreshing faster than a human could type). Three guarantees now hold by construction. First, no code path may reload the console more than twice in ten seconds: the third attempt is stopped by a sessionStorage counter, the stored session token (the classic loop fuel) is cleared, and the login page appears with a banner saying what happened, so the worst possible outcome of any future reload bug is one visible explanation instead of an unusable machine. Second, logging out and session expiry no longer reload the page at all: they switch to the login view in place, which removes the largest reload source entirely. Third, stale assets self heal: at boot the console compares its own served version to the server's and performs one guarded cache busting navigation when they differ, closing the F-006 family for origins that cached an old build before the no cache middleware existed. The audit of the exposure surface came back better than feared and is recorded here: both public deployments already serve over HTTPS with only the login page exposed, the login endpoint already carries per username and per IP brute force limits with timing safe verification, and a strict Content Security Policy with nosniff, frame denial and permissions lockdown was already in place; this release adds Cache-Control no-store on every API response so authenticated data can never be cached by a browser or shared proxy, and consolidates the header middleware after this session briefly duplicated it (caught by receipt, recorded per the receipts culture). Field note for the affected machine: update.bat needs no browser; run it, then reopen localhost once. |
| 2026.07.20-81 | OpenRouter joins the dedicated lines, and the AI card gains a key transit guard (app, no schema change; receipts green live). OpenRouter gets its own pre configured line (OpenAI wire format, base https://openrouter.ai/api/v1, key hint sk-or-v1-...), seeded idempotently so existing installs gain it on update; List models fetches OpenRouter's live catalogue, which is its whole point as a provider of providers, and its challenge checkbox works like every other line's. The transit guard exists because of a real incident: a configured Anthropic key incurred unexplained costs, and the most probable leak vector for a key in this platform is not the database (keys are write only and never returned) but the moment of pasting, when the key travels from browser to server; over plain HTTP on a network address that transit is cleartext. The AI card now shows a red, unmissable warning whenever it is served over http on anything other than localhost, saying exactly that, and telling the operator to treat any key ever pasted that way as leaked. Recorded here for the operational log: the incident response is revoke the key at the provider, create the replacement inside a dedicated workspace with a monthly spend limit and usage alerts, keep auto reload off so prepaid credits are a hard ceiling, and paste keys only at the server on localhost or through HTTPS. |
| 2026.07.20-80 | Dedicated provider lines and the challenge roster checkboxes (app, schema v35: one flag column; receipts green live). The AI card stops being a generic form: Anthropic, DeepSeek, OpenAI, Gemini and Llama each get their own pre designed line, seeded by the platform itself at startup with the correct wire format and base URL, so the operator supplies exactly two things: the key (each line shows the key format that provider actually issues) and the model, picked from the provider's own live catalogue through the List models action, enabled the moment a key is saved. This also closes the field report that DeepSeek offered no model choice: DeepSeek is now a registry line like every other, and the legacy Anthropic and DeepSeek keys migrate into their lines automatically at first start, so nothing configured is lost. Llama's line alone keeps its base URL editable, because a Llama host is the operator's own. Challenge mode becomes an explicit roster: every keyed line carries a challenge checkbox, and only ticked providers fight; the synthesis is produced by the first enabled provider, and a ticked provider that fails to answer appears honestly inside the synthesis rather than sinking it. The free form entry survives demoted as Advanced, for a provider not on the dedicated lines. The status paragraph now reports the registry (how many providers are keyed, by name) instead of the single legacy key. Receipted live: seeding of all five lines, DeepSeek's model selection round trip, and the challenge flag persisting both ways; T38 and T39 green on the final tree. |
| 2026.07.20-79 | Hybrid COV: bounded, prioritised, honest (bridge only, no schema change; finding F-009 from a 995000 line field log). The uploaded log's stack dumps carried the identical signature to F-008, the loop frozen past fifteen minutes inside the driver's COV subscription refresh, which settles the question at full fleet scale on this driver stack: unbounded COV is not fixable from our side tonight. What ships instead is the pattern the industry itself uses for exactly this reason: COV where a change IS the information, polling for the rest. In cov transport the bridge now subscribes a bounded shortlist, statuses and binaries first, then counters (kWh, Wh, m3), then power, hard capped at 300 subscriptions per bridge process under one GLOBAL budget shared across all sources (82 direct mode sources cannot each take 300), with the remainder honestly logged as updating at the heartbeat cadence. The shortlist is a pure, tested function and the cap is a field tunable constant. Receipted: T37 grows to twenty checks including the shortlist's cap and priority order; the full log evidence and the quarantine rationale live in the findings register. Full fleet COV returns when the driver stack advances or per device capability gating makes it safe; until then the default transport remains polling, and cov mode is a scalpel, not a firehose. |
| 2026.07.20-78 | The official release cut: one file installation and the documentation set (no schema change). install-everything.bat is the entire installation for a person with no IT background: double click, it elevates itself, installs Python 3.12 through winget when absent (with an honest manual fallback message when winget is missing), installs every Python dependency, opens the Windows Firewall for BACnet inbound UDP 47808 to 47820 in one named rule (the exact rule whose absence cost the Setsco campaign a night), registers the autostart services through the existing hardened installer, and ends with the browser address and the first login. Desk verified line by line as all Windows scripting is; the guide's install page now leads with it. Shipped alongside, outside the zip: the Release Status Report RSR 2.0 covering the entire -61 to -77 arc with findings F-001 through F-008, the Product Data Sheet, and the Product Information document for this release, both modeled on industry standard equivalents and carrying the platform's security posture stated honestly, the controls that exist (roles, forced first password change, write only secrets, audit trail, package verification on update, per project data isolation) mapped to ISO/IEC 27001 Annex A themes as operational support for a customer's ISMS, never as a certification claim. |
| 2026.07.20-77 | The open provider registry, living dashboards with a full history, and a site memory (app, schema v34: ai_providers, dashboard_versions, ai_qa; T39, nine live receipts). Any AI provider, present or future, registers in Admin, AI with a name, a wire format (anthropic or openai, the format rather than the vendor, which covers Anthropic, DeepSeek, OpenAI, Gemini through its OpenAI compatible endpoint, and any Llama host), a base URL and a key; a List models button fetches the provider's own current catalogue live, because catalogues change often, and the model is picked from that list. Challenge mode now challenges EVERY enabled provider that has a key: all draft, the primary synthesizes, a provider that fails answers honestly inside the synthesis rather than sinking it. The security question is answered plainly in this record: keys are write only by construction, no endpoint returns a key in any form ever, not masked, not partial (receipted: the listing carries only a boolean), keys never enter audit detail or logs; at rest they live in the platform's SQLite file, so the protection boundary at rest is the server's own file system, the standard on premise model, stated rather than hidden. Dashboards live now: the Ask panel gains an update toggle, and a question can add, replace or enhance charts, with the model returning a complete new spec that is validated before it lands; EVERY spec change, by AI or by restore, archives the previous version first, so the dashboard's whole evolution is browsable in a History selector and any step can be restored, the restore itself archiving what it replaces, so no step is ever lost in either direction. And the local memory the field asked for: every dashboard question and answer is recorded per site (ai_qa) and relevant prior answers are injected into new questions as context, labelled that data may have changed, never replayed verbatim, so the miniBMS interprets questions in the light of what it has already been asked, alongside the cross project lessons of -76. T38 and T39 green; live model traffic remains FAT campaign D's. |
| 2026.07.20-76 | Two AI providers, challenge mode, cross project lessons, and dashboards that talk back (app, schema v33: one ai_notes table; T38, thirteen checks). The platform's AI layer now runs on Anthropic or DeepSeek, switchable in Admin, AI, where the DeepSeek key is stored with the same never returned discipline as the Anthropic one. The elegance is in what did NOT get built: DeepSeek publishes an Anthropic compatible endpoint with full tool support, so the entire research tool loop runs on either provider by swapping URL, key and model, one implementation, two brains. Challenge mode is the third setting: for explanations and dashboard questions, both models answer the same prompt and the primary synthesizes the best of both drafts; for the research loop, the other model delivers a second opinion appended to the analysis, a critique from a rigorous colleague. Context caching came free by architecture: DeepSeek's disk cache keys on stable prompt prefixes, our per project system prompt IS that prefix, so each project forms its own cache unit automatically, and the new cross project material rides the variable tail on purpose. Which answers the field's sharpest question, remembering how a humidity issue was cleared on one site to reproduce on another: that is not caching (caches expire within days) but knowledge, so the platform now owns it: ai_notes, a cross project lessons store with its API, injected into every research and dashboard prompt by keyword relevance, capped and labelled as operator recorded. And saved dashboards become conversations: an Ask AI panel on the dashboard page carries a running thread (stateless, the client holds the history, exactly like every chat API), grounded in the dashboard's own definition, with one button that saves a good answer as a lesson every other site's AI can use from then on. Receipted: T38 green (provider round trip, challenge composer, lessons scoring finding the humidity note by keywords and staying silent on unrelated questions, dashboard conversation endpoint failing honestly without a key), T21 and T22 green; live model traffic is FAT campaign D's to prove, and api.deepseek.com is unreachable from the development container by network policy, so the DeepSeek transport is desk verified against the vendor's published compatibility table. |
| 2026.07.20-75 | The stack dump named the villain: COV is quarantined, polling is the default (bridge and app, no schema change; finding F-008, the answer to the whole freeze saga). The freeze detector delivered exactly what it was built for: across four minutes of dumps the event loop thread sat inside BAC0's COV subscription task (CoV.run into bacpypes3 refresh_subscription into app.request into constructeddata setattr), actively churning and never yielding. At 4965 points, largely on controllers that reject SubscribeCOV, the COV machinery of this stack monopolises the event loop, which retroactively explains every frozen scan since the first: the freeze always began the moment sources resolved, because resolution starts the subscribe storm, and the storm seizes the loop that every timer, sweep and watchdog lives on. The values the field observed arrived through polling, the path that has quietly worked all along. Decision, recorded: poll is now the default transport in the bridge and in the platform's bridge command builder; COV remains available strictly opt in per bridge, with its help text carrying this finding, until it is proven at scale on a newer stack or with per device capability gating. Receipted: a poll mode run resolves from cache and starts plain polling with no COV machinery anywhere in the log. The freeze detector and the pulse file stay, as permanent equipment. The field question is answered in one line: no, COV does not work on this stack at this scale, and the system no longer depends on it. |
| 2026.07.20-74 | The directed Who-Is subnet sweep: resolution without broadcast (bridge only, no schema change). The field asked the right question, whether this BACnet API offers another way to send a Who-Is, and it does: a Who-Is may be sent by directed unicast to a single address, no broadcast involved, and a machine whose broadcast collection misbehaves can simply ask every host of its own subnet directly. The resolver now runs three stages, each feeding the persistent device cache: the cache itself (instant), the broadcast five sweep campaign, and, for whatever remains, a directed unicast Who-Is to every host of the bridge's subnet, in paced batches of 24 with per probe timeouts, capped at 1024 hosts so a /16 is never swept, each answer logged with origin probe. Receipted live: a /28 swept 13 hosts in about a second alongside a cache resolution, every stage printing its own honest summary. Also fixed, from the same field log: the GetEventInformation off decision is now remembered per device, ending the re announce and re reject spam that filled the log (a rejecting device is asked exactly once per bridge run). The same log settled two larger facts worth the record: six devices were resolved and actively polled for many minutes with the 120 second timers firing throughout, so the event loop was alive and broadcast Who-Is has succeeded at least partially on that machine; and the absence of both the cache line and the announce dedupe shows the machine was still running -71, meaning the -72 cache ratchet, the -73 log detail and this release's directed sweep all still await their first field run. With all three stages deployed, every partially successful scan adds devices to the cache permanently; a site needs each device heard once, by any stage, ever. |
| 2026.07.20-73 | The log grows up: longer, denser, and answering the field's actual questions (bridge and console, no schema change). Per device resolution lines with provenance: every device that gains an address is logged as "device 400101 -> 192.168.10.51 (cache)", the origin naming cache, map or scan, capped at twelve lines per pass with an honest "and N more" so an 82 device site does not flood; receipted live with a seeded cache printing both fixture devices with their origin. A traffic summary once a minute per project, the single most reassuring line a site can print: values published in the last sixty seconds, sources serving out of total, errors; it runs supervised beside the sources. The 82 line alarm summary announce collapses to one per project (the -71 cosmetic item, closed). A --verbose flag exists for per source detail without burdening the default. Console side: the bridge Log view now shows the last 200 lines instead of 20, notes that fact, and carries a Download link that fetches the complete log file through a new endpoint (GET /api/bridges/(name)/log), driven live per the F-007 process rule. Riding fix, self caught: with a device map file present, the map load replaced the cache seeded mapping instead of merging into it, and its string keys mislabeled origins; the map now merges with integer keys. Compile, node check, and the cache path receipt green on the final tree. |
| 2026.07.20-72 | The device address cache: resolve once, remember forever (bridge only, no schema change). The field question deserved its direct answer in the record: the EDE carries device instances but never addresses, and BACnet offers no way to unicast to an instance without one, so a Who-Is scan (or a map file) is needed once; the improvement is making once mean once ever. The bridge now persists every address it resolves to data/bridge-devices-(name).json, merged and sorted, and seeds the resolver from that file at every start, before the map file and before any sweep. A restart therefore resolves instantly from disk, the scan only fills gaps and refreshes late joiners, and a site whose broadcast is flaky needs exactly one lucky scan in its lifetime. Receipted live in the container: a seeded cache resolved both fixture devices instantly, the log reading loaded from the device cache followed by every device resolved with no sweep in between, and the cache file preserved byte for byte. Worth restating from the field evidence while this ships: the -69 Setsco log already proved resolution, request and response working on that network (three named devices answered polls), and every subsequent still the same paste ends within two seconds of a sweep that needs three; the standing receipts that settle the machine's true state remain the pulse file read twice and the Points page after two minutes of runtime. |
| 2026.07.20-71 | One line, mine, and the lesson recorded (app only; finding F-007). The -70 bridge spawn hardening used os.environ in a module that never imported os; py_compile cannot see a NameError, the endpoint was not exercised live before shipping, and the field found it within minutes: every bridge start wrote its separator and then 500ed, the child never spawning, which is exactly the two silent bridge starts in the Setsco log. Reproduced in the development container on the first try, fixed with the import, and receipted live this time: the start endpoint returns 200 and the spawned child's first line lands in the log unbuffered. Process change alongside the fix: any edit touching a spawn path or an endpoint ships only after that endpoint is driven live in the container, not just compiled. The same field log deserves its milestone stated plainly: devices 400101, 100101 and 100201 received requests and answered them, their GetEventInformation rejects handled exactly as F-005 designed, capability off per device, nothing crashed; Who-Is resolution, request and response all work on the Setsco network, and what remains between this build and 4965 flowing points is nothing but this one import. Cosmetic item noted for later: the alarm summary announce prints once per source, 82 lines per start; it will be deduplicated to one per project. |
| 2026.07.20-70 | Two witnesses that cannot lie, and a stale cache buster that could (bridge, one spawn line, index.html; finding F-006). First the confessed bug: index.html hardcoded ?v=2026.07.20-47 on all four asset links since that release, so browsers were free to serve weeks old JavaScript and CSS regardless of what the server shipped; this explains the Scan button still looking like a ghost after the shiny one shipped, and possibly other phantom UI behaviour of the campaign. Bumped to -70 and recorded as a lockstep version item alongside VERSION.txt from now on, checked at every ship. Second, log lines can no longer silently vanish into buffering: the bridge reconfigures its stdout and stderr to line buffering at start, and the platform spawns every bridge with PYTHONUNBUFFERED set, so what the process prints is on disk when it prints it. Third, the pulse file: the freeze detector thread now also writes data/bridge-pulse-(name).json every five seconds, entirely bypassing stdout, carrying the wall clock time of writing, the pid, the loop age and a one word verdict (loop beating, or LOOP FROZEN). Whatever happens to the log stream, two reads of that file ten seconds apart answer the two questions this campaign keeps circling: is the process alive, and is its event loop beating. Receipted live in the container: the Linux bridge ran and its pulse read loop_age_s 1.0, verdict loop beating. Field protocol: update, hard refresh the console (Ctrl+F5, mandatory after the cache buster fix), confirm the version badge and update.log show this build, restart the bridge, and read the pulse file twice; then the log, the pulse and the badge together leave no room for a stale layer anywhere in the chain. |
| 2026.07.20-69 | Root cause found and fixed structurally: no task can kill the bridge again (bridge only, no schema change; finding F-005, the deepest of the Setsco campaign, and very likely the whole story of F-004). The field traceback was the confession: a device answered GetEventInformation with Reject unrecognized-service, and that reply killed the entire bridge, because bacpypes3 raises protocol replies (Reject, Abort, Error) as BaseException descendants, NOT Exception descendants, verified against the installed library (the mro runs ErrorRejectAbortNack straight to BaseException). Every except Exception in this file, all forty six of them, was blind to the one family of exceptions a BACnet bridge exists to receive; the first device to reject anything sailed through every handler, took asyncio.gather down with it (default behaviour: one task's exception cancels all), and the process died for the service manager to restart, which reads in a log exactly like a frozen loop: a sweep announced and then a fresh bridge start. This also carries the good news the traceback smuggled in: a device REPLIED to Python on that wire, and the alarm poller only runs against a resolved target, so requests, responses and resolution have all worked on that machine. Three layers of fix: a CATCHABLE family (Exception plus ErrorRejectAbortNack) replaces every except Exception, so the read ladder degrades on rejects instead of dying and the sweep logs them as failures; every gathered task now runs under a supervisor that logs any death with its traceback tail and restarts that one task ten seconds later, the containment the docstrings always promised, structural this time; and a device rejecting GetEventInformation is treated as a capability fact, logged once and polled never again, not an error to retry. The answer to the rewrite question is recorded here deliberately: the driver stays; the evidence shows devices answering it, and the defect was in this file's exception discipline, not in the stack. Compile and the full Linux bridge rerun green; the field receipt is the next Setsco run, which for the first time cannot die, whatever the 82 devices throw at it. |
| 2026.07.20-68 | The freeze witness gets faster and its receipt exact (bridge only, no schema change; F-004 continued). The Setsco run on -67 confirmed the Selector policy line printing and the freeze persisting, which retires the easy cure and promotes the stack dump to the main act. Two sharpenings: the freeze detector now fires after eight seconds of frozen loop instead of twelve and repeats every thirty, so the dump lands within ten seconds of the freeze and a short log capture cannot miss it; and the bridge logs the event loop class actually in use at runtime (SelectorEventLoop versus ProactorEventLoop), because the policy line stated intent and this line states fact. The full Linux bridge rerun stays green to the second after the change. Field instruction standing: restart the bridge, wait thirty seconds, and paste everything including the EVENT LOOP FROZEN block and the thread stacks under it; the frame at the top of the frozen MainThread stack is the answer to the whole hunt. |
| 2026.07.20-67 | The frozen loop gets a candidate cure and an inescapable witness (bridge only, no schema change; finding F-004 from the Setsco timestamped log). The -66 instrumentation convicted precisely by absence: sweep one announced itself and then neither the three second heard line, nor the ten second TIMED OUT, nor a single watchdog wake ever printed, which means asyncio timers stopped firing entirely; the event loop thread on that Windows machine is stuck in a synchronous call moments after the first Who-Is send. Two receipts frame the fix: the clean driver stack returned who_is in exactly 3.0 seconds in the development container, and the ACTUAL bridge, full context, ran the complete five sweep campaign to the second on Linux (sweep sent 15:43:17, heard at :20, all five, checklist, retry), so the bridge code is sound end to end and the freeze is Windows environment specific. The candidate cure, honestly labelled: on Windows the bridge now sets the Selector event loop policy before the loop exists, because Windows defaults to the Proactor loop and datagram heavy asyncio stacks are far more widely run on Selector, the field symptom being the classic shape of a Proactor rough edge. The inescapable witness, for the case the cure is not enough: a freeze detector runs as a plain thread, deliberately outside asyncio because every async instrument freezes with the loop it lives on; it watches the heartbeat the loop watchdog updates, and twelve seconds of silence makes it dump every thread's stack into the bridge log, naming the exact synchronous frame the machine is stuck in, at most once a minute. Compile and the full Linux bridge rerun green after the change; the field receipt is the next Setsco restart, whose log now ends this hunt one way or the other: either sweeps hearing devices under the Selector loop, or a stack dump that names the guilty frame in black and white. |
| 2026.07.20-66 | The scan becomes un-hangable and self-explaining, and the button becomes visible (bridge and console, no schema change). The Setsco log showed the campaign header and then nothing, which the old logging could not disambiguate: a paste cut three seconds too early and a wedged driver call read identically. First, the mechanism was reproved: the exact BAC0 2025.9.15 and bacpypes3 0.0.106 stack was run live in the development container and who_is returned an empty list after precisely 3.0 seconds on a quiet network, so the driver mechanism is sound and whatever differs on site is now cornered by instrumentation. Scan path log lines carry timestamps; every sweep announces itself before the call ("sweep 1/5 sent, collecting, hard limit 10s") so the log shows the await began; every sweep is bound by a hard 10 second timeout, so a driver call that never returns becomes a named TIMED OUT line stating plainly that the armed driver timeout was 3s and the suspicion therefore falls on a stalled event loop or a wedged stack, not the network; and a loop watchdog runs beside the sources, printing "event loop stalled N.Ns" whenever a coroutine that slept one second wakes late, which is the difference between blaming the network and blaming the process, written into the log at the moment it happens. The browse arrival line is timestamped too, so a command that never arrives, a scan that hears nothing, a hung call and a stalled loop now all read differently. The Scan network button moves from a ghost outline to the brand primary style (green gradient, white bold, shadow) and its results render in a bordered light green box; the F-003 alarm summary fix is confirmed live by the same log, polling cleanly with no AttributeError. T37 green on the final tree. Field request standing: run the scan, wait a full minute, and paste the timestamped lines. |
| 2026.07.20-65 | The Scan network button and the direct mode resolver now share one Who-Is campaign, so the button finally logs (bridge and console, no schema change). The -64 button drove the browse facet's single silent Who-Is while all the verbose sweep logging lived in the resolver alone, which is exactly what the field observed: a scan with no bridge log. Now there is one whois campaign in the bridge, used by both: five staggered sweeps, every sweep printed with what it heard and the first instances at their addresses, failures in full repr, the library's collected discoveredDevices merged in, and the zero I-Am checklist (the Windows Firewall inbound rule for python.exe first) printed by whichever caller ran the scan. The button streams live progress to the console per sweep ("sweep 3/5: 41 device(s) heard so far") and every browse command arrival is logged by the bridge on receipt, so a command that never arrives is now distinguishable from a scan that hears nothing. A ranged Who-Is stays a single deliberate probe. Riding along, an honest UI bug of the -64 button fixed: the job progress payload is a dict and rendered as [object Object]; it now renders its text. T37 green on the final tree; the live receipt is the next press of the button on the Setsco machine, which will either list 82 devices or print, in the bridge log, exactly why not. |
| 2026.07.20-64 | The scan explains itself, the network scan gets a button, and the last stale driver call falls (finding F-003 from the Setsco direct run, 82 devices, 4965 points). A complete audit of every BAC0 call in the bridge against the installed 2025.9.15 library found exactly one remaining stale name after -63: the device alarm summary's raw request, which the modern Lite does not carry (field receipt: AttributeError 'Lite' object has no attribute 'request'); it now goes through the underlying bacpypes3 application with the destination on the APDU itself, desk verified against the installed source, and its failure stays soft as before. The same audit cleared COV by signature: the subscription call matches the installed API argument for argument, so a silent COV points at resolution or at the network, not the driver. The direct mode scan is now deliberately verbose, because a failing scan must explain itself from the log alone: the pass opens by naming the interface, port, wanted device count and the driver method in use; every sweep reports how many I-Am replies it heard with the first instances at their addresses; exceptions print in full repr; and a scan that hears ZERO I-Ams prints the checklist in order: the Windows Firewall inbound rule for THIS python.exe on the bridge port (the allowance given to beez-BACnetBrowser does not cover Python), the subnet and broadcast reach, and the device map fallback. The project BACnet card gains the Scan network button: it sends a Who-Is scan from the selected bridge through the existing live browser job pipeline and lists every device that answers as instance at address, so the network question is answered from the console in one click before any EDE is involved. T37 and T21 green on the final tree; the live receipt is the Setsco restart on this build, whose sweep lines now tell the whole story either way. |
| 2026.07.20-63 | Direct mode scan fixed against the installed BACnet driver (bridge only, no schema change; finding F-002 from the first local direct field run, 82 devices). The field log was exact: AttributeError, 'Lite' object has no attribute 'whois'. Root cause is a driver API difference, precisely as suspected in the field: beez-BACnetBrowser rides the C# ela-compil stack and was never involved; the bridge rides Python BAC0, and on the installed BAC0 2025.9.15 the discovery call is the async who_is with low_limit and high_limit keywords, while the old synchronous name whois no longer exists on the Lite object at all (the library's own docstring still shows the stale name, which is the trap). The -62 changelog claimed the resolver reused the browse facet's field proven call; that claim was wrong in the way that matters: the resolver did match the browse facet, and the browse facet carried the same latent defect, failing softly on this BAC0 version. Both call sites are fixed through one testable picker that prefers who_is, tolerates the legacy whois, and fails with a named error rather than an attribute crash inside a sweep; the browse facet passes the range limits as keywords on the modern API; and both result loops normalize genuine I-Am APDU objects (iAmDeviceIdentifier, pduSource) alongside tuples and lists. Receipted at a standard this defect earns: the exact BAC0 2025.9.15 was installed in the development container and the fix verified against its real Lite class, the picker resolving the genuine async who_is with the keyword signature confirmed, and the bacpypes3 IAmRequest shape confirmed importable; T37 grows to eighteen checks including the picker's three behaviours; T21, T22, T37 green on the final tree. Live confirmation is the same field run that found this: restart the bridge on -63 and the five sweep scan should name devices within seconds. |
| 2026.07.20-62 | Direct mode resolution becomes a scan campaign (bridge only, no schema change, no application change). One Who-Is is never enough on a real network: routers drop parts of large I-Am bursts and MS/TP nodes join the token ring late, the same field evidence that gave beez-BACnetBrowser its staggered sweep campaign in 0.3.3. The direct mode resolver now runs five staggered Who-Is sweeps per scan pass (constants grouped at the top of the bridge for field tuning), the address mapping accumulates across sweeps, and resolution applies after every sweep, so a device found in the first second is polling before the last sweep fires. Devices the whole campaign never hears are named in the bridge status per device and a fresh five sweep scan runs every five minutes, forever, so a controller that appears an hour later simply joins then. Manual addresses are never required in direct mode; the --device-map file remains strictly an optional fallback for networks where broadcast is blocked. Remote mode is untouched on purpose: broadcast cannot cross the VPN, so the beez-hive gateway address stays typed exactly as before. The I-Am normalization is now a pure function tolerating every reply shape the installed BAC0 produces. Receipted: T37 grows to fifteen checks including the five sweep constant, normalization across tuples, lists and objects with garbage dropped, and sweep accumulation; the bridge compiles and the core chain T21, T22, T24 confirms the application is untouched; live campaign behaviour on a real network lands with FAT campaign E. OPS revised to 1.2 stating the scan behaviour plainly. Riding along: T22 gains the idempotent pre-cleanup the newer suites always had, after an aborted run (a reaped backend mid test) left fixtures that the unique name guard then correctly refused; the platform behaved, the suite is now unpoisonable. |
| 2026.07.20-61 | Local direct mode (no schema change; bridge, one API normalization, the project BACnet card, T37). The platform can now sit on the building's own network and talk to every field controller directly, with no beez-hive gateway and no VPN in between. The project BACnet card gains a mode choice: Remote (the beez-hive gateway over VPN, the standard OBM deployment, exactly as before) or Local direct, which stores the literal target "direct" and asks for no address. In direct mode the bridge splits the project's EDE, typically a whole network export from beez-BACnetBrowser, into one internal source per BACnet device using the device instance every EDE 2.3 row carries, then resolves each instance to an address: an optional device map file first (bridge option --device-map, a small JSON of instance to address, for networks where broadcast is blocked), Who-Is on the local network for the rest, retried every five minutes so a controller that joins late simply starts serving then, while unresolved devices are named in the bridge status instead of blocking anyone else. Everything downstream is deliberately untouched: reads with RPM and per controller degradation, COV subscription as the default transport with the heartbeat read, writes routing by point, schedules and device alarms per source, and the capability memory, which was already keyed by device instance and therefore fits direct mode without a line changed. Rows without a device instance are skipped by name. Rate control in this mode moves from the gateway covIncrement to the polling interval and, where a site needs it, the per unit throttle and deadband storage policies that have been dormant in the historian all along. Receipted: twelve T37 checks proving the pure per device expansion, the resolution bookkeeping down to every point (writes route by point), the no op second pass, and the API round trip storing and returning the lowercase literal; the full regression chain T21 through T36 green with no flakes; the live Who-Is call reuses the exact shape the browse facet has run in the field since discovery shipped, and real network behaviour lands with FAT campaign E. The Installation and Operations Guide is revised to OPS 1.1 with the two connection modes chapter. Wire schema untouched: topics are identical in both modes, so nothing needs a fleet ruling; the covIncrement governance note for direct mode goes to Didier informationally. |
| 2026.07.20-60 | Installer field fix from BEEZ-SERVER-02 (no schema change, no application change; only installer/install-windows.ps1). The service registration trusted nssm's own exit code after start, and nssm is impatient: a service passing through SERVICE_START_PENDING, a completely normal transient on a first start while Defender scans a freshly fetched binary, made nssm print an unexpected status message and exit non zero, and the installer threw with an empty broker.log tail read milliseconds after the start. Three changes, desk verified line by line because PowerShell does not run in the development container, with the field transcript as the diagnosing evidence: first, the Windows service manager is now the truth, polled for up to 30 seconds after start and RUNNING accepted regardless of nssm's patience; second, a genuine failure names the process actually holding the port (mosquitto.exe with its PID, for example) instead of pointing at a log that had no time to exist, with the stop-all.bat remedy stated in the error text; third, both services get a port preflight, 1883 for beez-mqtt and 8000 for beez-minibms, placed deliberately after the function's own cleanup so a leftover service of the same name from a previous install is stopped legitimately rather than reported as a conflict, and only a genuine foreign holder, typically a console mode instance left from bench use that Stop-Service never touches, aborts the installation with its name. The immediate field remedy for the observed failure needs no new package: sc query beez-mqtt usually shows RUNNING already, and re running the installer completes the registration idempotently. |
| 2026.07.20-59 | The function block diagram closes the code bound spec (CTRL-050, C3 release five, no schema change). The diagram is derived from the compiled AST at request time and never authored separately, so it can never drift from the program that actually runs: the derivation is a pure module, unit proven on the canonical single PID (inputs, the block with its literal constants, OUT, and the exact edges), on the gain schedule (the CASE node with its bands, the scheduling variable chain and every band's block under its control), and on branched IF programs with nested blocks; an unknown statement renders as a named node rather than a silent omission, and a broken program cannot even be saved, so the diagram never meets one. The Diagram panel in the loop editor renders the graph as SVG in the brand palette and merges the live truth from the engine snapshot every five seconds while open: the PV input lists every candidate with its validity colour, the serving sensor in dark green, an invalid one in the established alarm red with its named reason; SP names its source; every block shows its constants from the AST literals, which is the no drift guarantee stated as pixels; OUT shows commanded and actual side by side, the actual merged from the live cache of the output point; FAULT renders as an unmistakable banner; and cascade chains are named in both directions, the parent that resets this setpoint and the children this output resets. Editing in the graphical view stays out of scope for this release, phase C4 as the spec says. Receipted live: thirteen T36 checks across three canonical graphs and the wire, including the candidate refs riding the PV input, the OUT readback, the cascade pair named both ways, and the save gate refusing a broken program before any diagram could pretend; the complete regression chain T21, T22, T24, T25, T26, T27, T28, T29, T30, T31, T32, T33, T34, T35 green with no flakes this run. With this release every CTRL item of CTRL-FS-1.1 that can be satisfied in software is satisfied and receipted; what remains open is field gated by its nature: live AI content behind a real key, hardware verification of engine writes, schedules against real calendars, and the site validation campaigns. |
| 2026.07.20-58 | The editor assistant and the named profiles close the code bound spec gaps (CTRL-040 and CTRL-100, C3 release four, no schema change). The traceability list of CTRL-FS-1.1 was walked in full this release and diffed against everything shipped; two functional gaps remained and both land here. The loop editor gains its AI assistant per CTRL-040: describe the program in plain language and the assistant returns complete ST, amending the current program rather than starting over, bound to a language reference of the exact platform subset with invented points and aliases forbidden; or press Explain and the current program comes back in plain language, every constant in practical terms. Every produced program passes the compiler gate before the pane ever shows it, a failed gate names its coordinates and places nothing in the editor, saving remains the operator's explicit action, and the assistant is honestly dormant without a key and honestly refused by the provider with a dummy one. The named tuning profiles of CTRL-100 ship as the trio: Conservative (the anti overshoot default: low proportional gain, gentle integral, pronounced derivative, first order filtered inside the PID block by construction, a filter the engine has carried all along), Balanced, and Aggressive; a Seed constants button rewrites a single PID program with the chosen profile keeping its own clamps and action, the constants stay individually editable, seeding a gain schedule is refused toward manual editing, and the provisioning template now equals the Conservative profile exactly, correcting the TD zero simplification this changelog shipped in -53, which was under the spec's own definition. These constants are defaults, never measurements. Remaining to the full spec after this release: CTRL-050, the function block diagram rendered from the compiled AST, a view rather than a function, planned as the next release; everything else still open is field gated (live AI proposals behind a real key, hardware verification of writes, schedules against real calendars) rather than code gated. Receipted live: fifteen T35 checks including the profile ordering, the template identity, seeding on a clamped program, both honest failure modes of the assistant and the pre model refusals; the complete regression chain T21, T22, T24, T25, T26, T27, T28, T29, T30, T31, T32, T33, T34 green with no flakes this run. |
| 2026.07.20-57 | Boost mode and the gain scheduling contract (CTRL-140 and CTRL-120b, C3 release three, schema v32). A boost definition lives per space or group: the member selection by class, equipment or explicit loops, the declared sensors of the target aggregate (MEDIAN by default, robust to an outlier; empty means the members' own PVs), the boost setpoint, the comfort band, the maximum OUT authority and the mandatory hard time limit (default 120 minutes, at most 600, refused absent). A manual start pushes only AUTO members: the setpoint overlays through the worker's own resolver, the authority caps the output after every scan, TEST freezes are respected and named in the start reply's skip list, and FAULT is never overridden. The boost stops itself with the boost-done event when the aggregate enters the comfort band around the median of the members' own setpoints, the hard limit lands as boost-timeout, a manual stop is recorded, and the members return to their own setpoints the moment the session ends. Sessions are supervised runtime, never stored modes: they ride the state snapshot with their live aggregate, survive reloads through the runtime carry, and simply do not exist after a worker restart, consistent with TEST semantics. Schedule driven start (building opening, event start) is deferred to the schedules chapter, which still waits on its hardware gate. Gain scheduling per CTRL-120b is authored ST by design and the language has carried CASE since the beginning, so this release delivers the contract: the AI review offer now includes a canonical CASE over a declared scheduling variable exemplar, compile proven with its three bands, bound only to declared loop inputs with invented bindings forbidden, proposed by the AI and applied by the operator through the existing proposal path; the engine never changes its own constants. The Control tab gains the Boost card (create, start, stop, typed deletion, live aggregate) and the platform gains the control events listing endpoint. Receipted live: twenty T34 checks including the overlay on AUTO members only, the authority cap on a deliberately aggressive member, the TEST freeze untouched, band entry self stopping, the timeout, the manual stop and the return to normal setpoints; the v31 to v32 migration proven on boot; the full regression chain T21, T22, T24, T25, T27, T28, T29, T30, T31, T32, T33 green; the known T26 chain flake made its fifth appearance under load and passed twice in isolation, queued unchanged; two test bugs of this suite's own making fixed along the way, the asynchronous mode confirmation now polled per the T24 pattern and the typed AUTO confirmation the house rules always required. |
| 2026.07.20-56 | AI assisted control review (CTRL-130 and 131, C3 release two, schema v31). Every loop carries an objective, ENERGY, TEMPERATURE, HUMIDITY or COMFORT, validated and normalised at every save, editable in the loop editor, and injected into every review as context beside the loop definition, the recorded metrics of CTRL-101 with their stated coverage, and the tail of the loop's own control events. Three review actions ride the existing AI job pipeline with its honest failure discipline, its in flight cap and its restart orphan recovery, and all land in the new proposed actions queue: Review this loop (a metrics driven proposal with reasoning), Improve with an issue (the operator writes plain language and the prompt binds the proposal to exactly that complaint, verbatim, with the instruction to say how), and Review this group (one model call, one verdict per equipment, stable, needs attention with the named reason, or failed with next steps, fanned into per equipment rows sharing a group key; a member the reply omits fails honestly). The prompts are pure functions, unit proven, and they instruct the model toward null under thin evidence and forbid invented numbers; the reply parser accepts only the JSON contract and fails honestly on prose. Nothing applies itself: applying is an admin action through one endpoint, where a constants proposal regenerates the strict single PID source keeping its own clamps, a full ST proposal passes the compiler or is refused with coordinates, a group verdict refuses application because information is not an action, a null proposal has nothing to apply, and a decided proposal refuses a second decision; dismissal closes proposed and failed rows. The Control tab gains a Review button per row (empty input is a metrics review, text is an improve complaint) and the Proposed actions panel with reasoning, the proposal in plain terms, and Apply and Dismiss. Receipted live: twenty three T33 checks, including the dummy key provider refusal recorded honestly through the real pipeline for both the single and the group runs, and the full queue lifecycle proven with synthetic rows because the queue is the contract; the v30 to v31 migration proven on boot; the complete regression chain T21, T22, T24, T25, T26, T27, T28, T29, T30, T31 and T32 green with no flakes this run. What a real key adds in production is only the content of the proposals; every path around them is already receipted. |
| 2026.07.20-55 | Phase C3 opens: relay auto tune and recorded performance (CTRL-101 and CTRL-110, plus the live retune promise of CTRL-100, C3 release one, no schema change). Every loop now computes rolling metrics over a 24 hour window: integral absolute error, actuator travel as the wear proxy, an oscillation index from zero crossings, and, from a mechanical SP change episode tracker, overshoot percentage and settling time; the window states its own coverage, because a freshly restarted worker must never present ten minutes of data as a day. Metrics ride the state snapshot and a loop endpoint, and they are the evidence base the coming AI review actions will cite. The relay auto tune (Astrom and Hagglund) runs only in TEST: the plant stays frozen at priority 1 throughout, the experiment writes a bounded relay at the loop's own priority with clamps intact and slew deliberately bypassed, the operating point is the loop's current output or an explicit base_out, and after six measured half cycles the run records ultimate gain, period, amplitude and two constant sets, the conservative Tyreus and Luyben beside the classic Ziegler and Nichols, as a control event. A measured period at scan resolution aborts with a named reason instead of proposing garbage dressed as measurement; excursion and timeout abort likewise; leaving TEST or a fault cancels the experiment by name. Applying a proposal is an explicit operator action that regenerates the source only when the program is exactly one PID call with literal arguments, keeping its own clamps and action, round trip proven through the new extract_pid_args and render_pid_source; anything else is refused toward the editor. The apply lands live and bumplessly per CTRL-100, which surfaced and forced the fix of a real reload defect: runtime truth (mode, holds, write continuity) was carried across reloads only when the program hash was unchanged, harmless while edits were refused on active loops, but the live retune path would have stranded a TEST freeze on the ledger while the worker forgot the mode; runtime truth now carries on every reload and only the compile state stays hash gated, so a retuned live loop re-engages bumplessly at its current output while a true worker restart still cancels stored TEST at reconciliation, receipted by T24. Also found by receipts, not theory: recreated loops inherited a deleted loop's tune history through SQLite rowid reuse, fixed by filtering events to the loop's own lifetime; and the development environment note that killing the supervisor orphans worker subprocesses whose live MQTT subscriptions then fight their replacement, now part of the restart discipline. Receipted live: eighteen T32 checks, ten pure and eight on a genuine closed loop plant thread integrating first order dynamics against the engine's real /set writes, including a completed relay run, both constant sets, the applied source, the experiment's own oscillation evidence in the metrics, and a named operator cancel; full regression T21, T22, T24, T25, T27, T28, T29, T30, T31 green; the known T26 chain flake made its fourth appearance under load and passed twice in isolation, queued unchanged. |
| 2026.07.20-54 | Cascade proposals complete the provisioning story (CTRL-163 with the acceptance flow of CTRL-164's report wiring, C2 release six, no schema change). Where the feeds topology links a supply template equipment to zone template equipment, both freshly drafted in the same run, provisioning proposes the pair as linked drafts: the outer comfort loop resets the inner supply loop's setpoint, the inner setpoint becomes the cascade, and the previous system's setpoint stays on the outer loop per CTRL-162, covering the case where the old control had one loop and one setpoint while the new design cascades. A proposal is made only when the topology is unambiguous: one fed zone is a pair, more than one is named ambiguous in the report and left as plain drafts, because arbitration between zones is a design decision, never a guess. Proposed pairs are inactive by construction: both the single and the bulk mode gates refuse activation with the named reason until acceptance, accepting either member flips both to provisioned drafts through one endpoint and one Accept pair button on the group table row, and accepting twice is refused honestly. Re-provisioning respects the human verdict at every stage: a pending proposal is never rewritten by a re-run, and a refresh never undoes an accepted cascade's setpoint wiring, which is preserved exactly like an edit because acceptance is an operator decision. The report and its CSV gain the cascade, ambiguous and pending sections; the run options gain the cascades toggle per CTRL-167. Receipted live: fifteen T31 checks on an AHU feeding one FCU (the pair) and an AHU feeding two (the named ambiguity), including both activation gates, acceptance by the outer member flipping both, the pending proposal surviving a re-run, and the accepted wiring surviving a refresh; one test expectation corrected during the run (the CSV is a whole project preview, so it names fresh pairs only, which is the engine behaving as designed); T21, T24, T25, T27, T28, T29, T30 regression green; the known T26 chain flake appeared once under load and passed twice in isolation, third sighting, queued unchanged for the instrumentation session. |
| 2026.07.20-53 | Automatic loop provisioning (CTRL-160, 161, 162, 165, 166 and 167, C2 release five, no schema change; the columns waited since v26). A code shipped, versioned template registry keyed by the equipment's Brick class (fan coil and VAV zone comfort, AHU supply air) turns the role and writability inventory of an import into DRAFT loops in mode OFF, on bundle import completion and on demand per project. PV binds through the candidate machinery of 4.6: every matching sensor role enters the ordered list, room temperature before return air, offsets at zero until the operator records a mounting height, because no height is ever invented and an estimate is never dressed as data. OUT takes the highest preference writable actuator role (cooling, heating, then fan speed); tuning seeds from the Conservative profile; equipment without a confident match is listed with the named reason, no writable output role or no PV role, never silently dropped, and loop telemetry equipment is excluded by construction. The setpoint source is a first class property per CTRL-162: EXISTING binds the previous system's writable setpoint so whoever writes it keeps owning it, LOCAL initialises from the last known value of a read only setpoint when one exists, and the run policy chooses between them with safe defaults. Re-provisioning is idempotent and respectful: an edited loop, detected by the compiled hash differing from the recorded provisioned hash or by manual origin, is never touched; unedited drafts refresh to the current template without duplicating; drafts whose equipment disappeared are flagged orphaned and never silently deleted. Every applied run persists a report as a control event and the current preview exports as semicolon CSV; the Control tab gains the Provision drafts button with a preview naming every count and reason before anything is written, plus the report CSV link. Provisioning creates definitions only and cannot write to the field by construction: every draft is born OFF and activation stays the explicit operator action it always was, through the group table shipped in -48. Deferred to the next release, as planned: CTRL-163 cascade proposals and the provisioning report page with its activation wiring (CTRL-164). Receipted live: seventeen T30 checks on a purpose built FCU fixture, including both named unmatched reasons, the ordered room then return candidate pair, EXISTING and LOCAL setpoint binding at the last known value, a re-run refreshing without duplicating, an operator edit never touched again, the persisted report event and the CSV; the import completion hook is exception guarded so a provisioning error can never fail an import; the full regression chain T21, T24, T25, T26, T27, T28, T29 green with no flakes this run. |
| 2026.07.20-52 | Operator subscriptions and assignment notices close the notification chapter (CTRL-152, C2 release four, schema v30). Any operator subscribes themselves per project with category filters (empty means every category), a minimum severity on the info, warning, critical, fault ladder (an unknown severity ranks as info, so a new class is never silently dropped by every filter), and their choice of channels; the filter is a pure function, unit proven. An alarm raise on the single writer event path fans out to every matching subscription, the designated contacts keep receiving every controls and fault alarm per CTRL-151, and fault severity stays immediate and undigested; the fan out is exception guarded so event persistence can never break on a notification error. Maintenance task assignment, at creation and at reassignment, notifies the assignee on their configured channels with the task title, the equipment and the due date. The Admin tab gains the notifications card: project contacts, personal channels with test buttons, per project subscriptions, and the write only Telegram token field that never echoes. Provenance, stated plainly: this slice was authored by a branch of the development conversation outside the shipping session, was audited here line by line against a verified -51 baseline before adoption, received one audit fix (the subscribable category list now covers comms and alarm-quality, which the alarm worker emits), and carries its own T29 suite. Receipted live on the audited tree: fifteen T29 checks twice back to back, including a matching raise reaching subscriber and contact, a fault raise hitting the sink immediately, a below filter raise queuing nothing (asserted on the queue table itself), and the assignment notice naming task, equipment and due date; the v29 to v30 migration proven on boot; T21, T24, T25, T27, T28 regression green; one T26 chain flake observed once under load, twice green in isolation, queued with the T24 flake for a dedicated instrumentation session. |
| 2026.07.20-51 | Notifications part one (CTRL-150 and 151 plus the CTRL-073 delivery chain, C2 release three, schema v29). Two channels: SMTP email with host, port, credentials and sender in configuration, and a Telegram bot whose token lives in the write only secrets store beside the FDD key and never leaves the server. Both ship dormant: an unconfigured channel is skipped at enqueue time, so the platform never queues mail it can never send, and production stays silent until the configuration is filled. The sender is a durable queued background thread: the queue is a table, so a restart never loses an alert; delivery failures retry on an exponential backoff from 60 seconds capped at one hour with five attempts before an honest failed status; a per channel rate cap admits thirty messages per sliding minute; and a digest window (default 120 seconds) folds a burst for one recipient into a single message naming the count. Users gain email and Telegram chat id fields they set themselves; each project designates a main engineer and a main technical support, both admin users, through a contacts endpoint. FAULT transitions reach the designated contacts immediately on every configured channel, bypassing the digest window and the rate cap both, because a plant fault must never wait behind housekeeping; the hook rides the existing single writer event path, closing the CTRL-073 delivery requirement. The folding, backoff and admission decisions are pure functions, unit proven before the wire. A test endpoint queues a message to the caller and names exactly why nothing was queued when nothing was; every delivery and failure lands in a log with an admin endpoint. This release also fixes a re-entry guard defect shipped in -49 and surfaced by suite runs: the guard armed on every invalid observation, including boot time staleness for candidates that had never served, so a worker restart could block every candidate for a full minute and strike an active loop toward FAULT on nothing; the guard now arms only on a valid to invalid transition, because it exists to stop flapping on recovery, never to punish a sensor for the platform's own restart. Receipted live: nineteen T28 checks with a local SMTP sink speaking real SMTP and a local stub speaking the Telegram bot API shape, including a FAULT published on the real event path reaching both channels immediately, a burst folding into one digest, a refused delivery retrying on the backoff clock, and the write only token; T26 extended with the boot scenario and green twice back to back; T21, T24, T25 and T27 regression green. Deferred to the next release: CTRL-152 operator subscriptions with category and severity filters, and maintenance assignment notifications. One T24 timing flake was observed once in a long suite chain under load, did not reproduce in isolation or in pairs, and is queued for instrumentation. |
| 2026.07.20-50 | Candidate validity grows its judgment (CTRL-070 and 071 completion, C2 release two, no schema change). The sensor plausibility engine joins candidate validity: the exact bounds tables and role matching that classify sensor faults in alarms now judge every candidate's raw reading inside the control engine, at evaluation time, instant at the next scan, with no cross process coupling; a physically impossible sensor is invalid before any calibration is applied, and the verdict names the implausibility in the snapshot. The alarm side keeps its own richer wording untouched; the rules live once. Most logical selection ships as an aggregation, defined mechanically per the specification: valid per rules, and closest to the median of the valid set when more than two are valid; with one or two valid the preference order decides, ties go to the preferred candidate, a pin overrides as everywhere, and re rankings between healthy sensors are never logged as failovers, only a change forced by the loser going invalid is. The mounting height estimate label now rides the wire end to end: a candidate with a recorded height and a configured offset is an estimate, a served PV sourced from one marks the loop snapshot pv_estimate and the group table shows (est) beside the value, and a blended aggregation that includes any estimate is itself an estimate; no stratification coefficient was invented, because the offset is configured by the operator and an estimate is never dressed as physics. Receipted live: twenty T27 checks, twelve pure and eight on the wire, including a chilled water candidate driven physically impossible failing over with the implausibility named against a wide open loop range, the median closest sensor serving in a three sensor most logical group, the mounting offset applied and labelled, and a measured PV never labelled an estimate; T21, T22, T24, T25, T26 and the alarm quality suite regression green. |
| 2026.07.20-49 | Candidate sensors open phase C2 (CTRL-070, 071 and 072, C2 release one, schema v28). A loop PV, and any declared input alias, becomes an ordered candidate list mixing BACnet and IoT points freely; each candidate carries a static offset (a calibration, applied before the validity range judges the corrected value), a validity range, a maximum credible rate of change, its own staleness limit borrowing the loop's freshness rule when unset, and a mounting height whose configured offset is labelled as an estimate wherever it appears. Aggregation over the currently valid candidates: first valid (ordered failover), average, median, min, max, sum; the method belongs to the alias in its own small table, never smuggled onto a candidate row. Failover is automatic, next scan, logged as a control event naming the loser and its reason, and reversible without operator action through a mechanical 60 second re-entry guard: every invalid observation blocks a candidate for the full guard, the serving candidate is exempt from its own history while it stays valid, and a recovered primary re-takes only after staying clean. The operator can pin any candidate at any time, active loops included, through a runtime endpoint that clears sibling pins; a pin overrides the order and the guard both ways, and a pinned invalid sensor serves nothing, honestly, because the operator overrode automation. A candidate only PV satisfies every activation binding gate with an empty single point, no valid candidate rides the existing three strike machinery into FAULT per the loop's fallback, resolved aliases feed IN.* execution, and the state snapshot carries the active sensor and every candidate's verdict; the group table shows the active sensor and the loop editor gains the candidate list with pins. The selection rules live as pure functions with no IO, unit proven against a fake clock before they touched a wire. Receipted live: thirty T26 checks, twelve pure and eighteen on MQTT, including failover within a scan, the offset on the served PV, the guard holding a recovered primary, a runtime pin, candidate only TEST entry, the edit refusal while active and average served on the wire; T21, T22, T24 and T25 regression green; the v27 to v28 migration proven on boot; schema pins moved to v28. Deliberately deferred, per the plan: CTRL-073's controls category alarm and its notification chain arrive with the notifications release, and the plausibility engine verdicts join candidate validity in a coming C2 release. |
| 2026.07.20-48 | Group operations close the coded half of C1 (CTRL-060 and CTRL-061, C1 release five). The Control tab becomes the equipment group table: a checkbox per row with select all, a Brick class filter derived from the digital twin, a Class column, and the commanded output next to the actual live value of the bound OUT point, side by side, so what the engine asks and what the wire answers sit in one glance. Bulk actions operate on the current selection: set TEST, set AUTO, set OFF, through one endpoint that resolves the selection by explicit rows, by Brick class, or as the whole project. TEST and AUTO demand the platform's typed confirmation naming the count ("TEST 5"); OFF never asks, because stopping must always be the easy direction. Every rule of the single loop path applies unchanged per loop: the legality matrix, the binding gate, the engine online requirement; ineligible loops are skipped and named with their reason, never silently ignored, and a preview returns the exact partition before anything moves. The audit trail records one bulk summary event plus every per loop transition marked as bulk. The stale Control tab introduction from the shadow era was corrected: the engine writes to the field now, and the page says so. Receipted live: thirteen T25 checks covering the partition, the wrong and the right typed count, the Brick class selection flipping exactly the VAV loops, the refused direct OFF to AUTO, the unconfirmed bulk OFF, and the audit rows; T21, T22 and T24 regression green; the DOM ship gate at parity with the baseline (a pre existing points tree flake on synthetic fixtures reproduces identically on untouched -47 and is queued for its own session). What remains of C1 is field validation on real hardware. |
| 2026.07.20-47 | The engine takes the plant, with a signature (C1 release four). Didier signed the /set payload extension, so the wire gains optional priority (1 to 16) and source fields; the bridge honors them in lockstep, the priority contract is pure and unit proven, and the signed fleet default stays 16: every pre extension publisher behaves exactly as before, and the default is one named constant if the fleet ever moves it. Loops gain their write side: an OUT point (writable, validated), a write priority, output clamps and slew limiting, a fallback policy (release, hold, preset) and a test ceiling. The full CTRL-090 choreography ships: OFF to TEST reads the live output value and freezes it at priority 1 before anything else moves, the engine then commands at its own priority visibly below the freeze; TEST to AUTO demands the typed loop name and releases exactly the freeze; every OFF releases exactly what the holds ledger records, and the ledger lives in the database through the single writer. A forgotten test ends itself at its ceiling. A crashed engine reconciles from the ledger alone, never blind: only levels this engine recorded holding are relinquished, interrupted tests are cancelled by name, and an AUTO loop resumes its own level. PV freshness is mandatory while active: an active loop never executes on missing or stale input, three strikes enters FAULT through the fallback, and the priority 1 freeze is always preserved so a failing test never bumps the plant. Mode changes need the engine online, direct OFF to AUTO is refused, editing an active loop is refused, and the Control tab carries the mode buttons with the AUTO confirmation. Receipted on the wire: seventeen T24 checks captured every /set the engine published, including the freeze first, the exact releases, the dead man refresh, the timeout, the crash reconciliation and the preserved freeze under fault; twenty four suites and the browser gate green. The remaining boundary is hardware: the first write against a real controller is the field gate. |
| 2026.07.20-46 | Third parties can read our loops (C1 release three, CTRL-007 and CTRL-008). The bridge gains a read only server facet behind a per bridge loop_objects switch: its own BACnet device hosts one standard Loop object (type 12) per control engine loop, mirrored live from the worker's retained state over MQTT, no database coupling, restart proof because the retained snapshot is the mirror source. Property mapping: present value is the loop OUT, controlled variable value is the PV, setpoint is the SP, action and engineering units carried, reliability and status flags track loop health, and the tuning constants appear only when the program is honestly a single PID call with literal arguments (a new compiler extraction that refuses to guess; wrapped or computed programs publish no constants). Object instance numbers are the platform loop ids, globally unique; a per project device numbering scheme stays a parked fleet decision. Third party writes are not accepted: a write attempt leaves the value untouched. Receipted over real BACnet in the loopback: a second, independent client on another port read the object list, names, values, constants, action and reliability, saw a live update propagate, and failed to write; plus a new contract suite (T23) covering extraction honesty, the property mirror and the live snapshot enrichment. Field interoperability against a third party head end remains the hardware gate. The engine itself stays wire silent; the write path still awaits its fleet sign off. |
| 2026.07.20-45 | The engine runs, in shadow (C1 release two). A third supervised process, the control worker, follows the alarm worker pattern exactly: spawned with restart backoff, read only database, its own MQTT connection, events persisted by the application as the single writer, retained state snapshots per project, a heartbeat with app side supervision, and a log of its own. Loops gain their bindings (schema v27): a PV point, and a setpoint sourced from a local value, an existing point, or the cascade parent's output; a definition without a PV binding stays a draft. Every bound loop executes on its scan cycle against live values, parents before children, and its PV, SP and OUT are republished as calculated platform points (labelled shadow), so Trends, alarms, dashboards and the AI see loop telemetry with zero new code. The Control tab shows the engine banner and live health, PV, SP and OUT per loop. Wire silent by design: the engine cannot publish to /set; the priority extension remains gated on its fleet sign off, and the holds ledger reconciliation runs as a loud warning skeleton. Two bugs died in the receipts: a ghosted retained snapshot after a project lost its last loop, made restart proof by having the worker rebuild its published set from its own retained namespace; and an unguarded main loop that could die silently, now confessing any exception to its log and carrying on. Receipted end to end: two live loops against real MQTT values with the cascade setpoint following the parent output, telemetry self provisioned, events persisted, deletion emptying the shadow (T22), five browser assertions, twenty two suites and the regression gate. |
| 2026.07.20-44 | The Control Engine begins (CTRL-FS 1.1, phase C1 release one). A new Control tab authors control loops in a safe subset of IEC 61131-3 Structured Text: assignments, IF and CASE, TIME literals, and a function block library with PID (clamping anti windup, derivative on the measurement through a first order filter, bumpless first scan, and an ACTION argument for reverse acting cooling processes), TON, TOF, RAMP, HYST, LIMIT, MIN, MAX, ABS, AVG, MEDIAN and SUM. The text is the single source of truth: saving compiles, invalid programs are rejected with line and column, and the compiled hash is recorded (schema v26: control_loops, control_inputs, control_events, control_holds). Cascade is a loop property with cycle rejection at save. A closed loop evaluation runs the program against a first order plant (PV relaxes toward ambient minus gain times output), so a cooling PID is testable against simulated physics before any engine exists; results are labelled as estimates. Deliberately absent, per the specification's own gate: nothing in this release can write to the field. Modes stay off, the /set priority extension awaits its fleet sign off, and the engine process arrives in the next release. Receipted with sixteen language and physics pins including anti windup, bumpless transfer and a no undershoot cooling settle, a live CRUD contract (T21), six browser assertions and the full regression gate. |
| 2026.07.20-43 | Automatic FDD for built in checks. Field question: setpoint deviation alarms come from the built in comfort checks, not from rules, so where is their opt in? Answer: the manual Diagnose button always worked on them (the prompt is built from the alarm itself, not from a rule row), and the automatic path now covers them too. Basic alarms carry no rule row, so the opt in lives on the project (schema v25): a card on the Alarms rules panel lists the four diagnosable built in checks (setpoint deviation, high humidity, low humidity, high CO2) with one checkbox each; ticked checks queue a diagnosis on every raise under the exact same cooldown, concurrency and persistence policy as rule alarms, and unknown check names are rejected by the API with the known list. Device reported alarms stay excluded: they already name their own fault. The FDD discipline also learned to check sensor plausibility first: a reading that is flatlined, exactly zero, or physically implausible points at the instrument, not the plant, which is exactly what a 2 degree room or a zone reading zero in the field data means. Receipted with simulated worker raises: opted in check queued and ran, non opted check silent, cooldown honoured, validation rejected junk; five browser assertions and six new contract pins. |
| 2026.07.20-42 | Automatic FDD. Alarm rules gain an opt in flag (checkbox on creation, one click toggle in the rules list): when an opted in rule raises (the normal to unack transition only), the platform queues the same root cause diagnosis the Diagnose button runs, with no operator present. Flood safety is structural, not hopeful: one diagnosis per equipment per cooldown window (ai.auto_fdd_cooldown_h, default 6 hours), at most two automatic runs in flight, everything else skipped with a named reason in the server log, and rules that are not opted in cost nothing at all. Every run is persisted in a new store (schema v24) with queued, running, done or failed status, so a 03:00 diagnosis is still waiting at 08:00; alarm rows whose key has a stored run show a Diagnosis action that renders it on the AI page exactly like a live answer, including save as dashboard, and failed runs state their honest reason in place. Runs orphaned by a platform restart are marked failed with that reason at the next start instead of pretending to still run. The trigger rides the worker's existing alarm event stream: no worker changes, no new topics, no wire changes. Receipted end to end by simulating worker raises over MQTT: queue, cooldown skip, opt out silence, a real API rejection stored verbatim, orphan recovery, plus seven scripted browser assertions and a new contract suite (T20). |
| 2026.07.20-41 | Alarm root cause (FDD). Every alarm row gains a Diagnose action beside Explain: it hands the alarm to the AI page as a full analysis job (live steps, cancel, save as dashboard all apply) with a prompt that names the alarm and instructs the model to walk the upstream feeds chain and compare commanded against achieved at every hop. The walk itself is a new AI tool, get_upstream_chain: it traverses the confirmed relationships (Admin, Proposed relationships) upstream or downstream, cycle safe, up to four hops, and returns each hop with its points and an honest origin label (confirmed by whom, or mined and unconfirmed); an empty topology returns a note naming the remedy instead of pretending. The naming convention does not encode which AHU serves a VAV or which chiller serves an AHU; the confirmed relationships are that knowledge, so confirming them is what arms this feature. Also fixed on the way: relationship confirmations recorded an empty confirmer name; they now record the username. Receipted with a synthetic chilled water, AHU and VAV plant walked live in both directions, three new contract tests, six scripted browser assertions, and the full regression gate. |
| 2026.07.20-40 | The invisible tab. Field report: a saved dashboard seemed not to save, suspicion fell on a colon in its name. The colon is acquitted with receipts: a dashboard named with a colon, an ampersand and quotes creates, lists exactly, round trips intact, renders in the report head and deletes with its exact typed confirmation. The real defect: every tab panel is its own main element and the tab dispatcher unhides main.panel, but the Dashboards panel shipped in -34 as a section, so it never became visible; the dashboard saved perfectly and rendered into a hidden panel, which on screen reads as a blank tab and a lost save. The panel is now a main like the others. Around it, the save flow stops requiring archaeology: the saved message carries an Open it now button that lands directly on the saved dashboard whatever project the tab was showing, the list and the deep link share one open path, and the empty list names the project it is scoped to, so a dashboard saved on one site is never mistaken for missing while another site is selected. Five scripted browser assertions on the hostile name and the deep link, all green; the visibility assertion that would have caught the section is now part of them. |
| 2026.07.20-39 | An answer that says why there is no answer. Field report: the job pipeline delivered a four step analysis in 50 seconds and the page showed No analysis was produced; the final step generated for 27 seconds and returned no text block at all, and the platform had nothing to say about what it did receive. Two causes addressed together. First, the per round token budget was hardcoded at 2000, starving the final JSON contract on deep questions and, on newer model generations, leaving room for the whole budget to be consumed before any answer text; it is now 4000 by default and configurable (ai.max_tokens in config.json). Second, observability: every round records the stop reason, the final round records the content block types, an empty final answer becomes a named diagnosis (stop reason, blocks received, the remedy), a truncated plain text answer is kept and flagged, and the trace line under the summary shows a non clean stop. The duplicated step lines in the F12 ring (logged live and again after the answer) are gone. T19 pins the empty answer diagnosis, the truncation flag and the budget clamp. The next empty answer, if any, arrives with its own explanation attached; that explanation decides whether a model specific parameter is needed, based on evidence instead of guesswork. |
| 2026.07.20-38 | No console request may depend on a proxy's patience. Field report, straight from the F12 ring: a deep question on a slow model returned HTTP 200 with a plain text body reading Proxy error at 60032 milliseconds; the reverse proxy in front of the server has a 60 second upstream timeout, gave up on a legitimately long analysis and answered for the platform while the analysis very likely completed into a void. The architecture answer: analysis becomes a job. Submitting a question returns a job identifier in milliseconds; the page polls the job with fast requests, streams every step live into the status line and the diagnostics ring as it happens, and a Cancel button stops a run cooperatively between steps. Both ask surfaces (the AI page and the embedded widget on Trends and Graphics) ride the same runner; finished jobs are kept for fifteen minutes; the fifteen minute client ceiling reports honestly that the server keeps working. Receipts: submit in 126 milliseconds on the machine that saw the 60 second wall, honest failure propagation through the job, live step streaming and cooperative cancel pinned in T19, six scripted browser assertions green. And the favicon exists at last (the beez hexagon), removing a permanent 404 line from every diagnostic session. |
| 2026.07.20-37 | The week long crash loop, cornered without its log. Since the first field log of the week the alarm worker exited with code 1 and restarted every five seconds on the production server, while the identical build never crashed on the Linux development machine. Root cause, found by reading the child with Windows eyes: the worker opened its databases read only through a URI built by gluing the file: prefix straight onto the absolute path. On POSIX that string happens to be a valid URI; on Windows the drive colon and backslashes make it no URI at all, SQLite answers unable to open database file, and the worker died at startup, deterministically, forever. The same construction sat in the loop advisor's history read, so that feature was silently broken on Windows too. One canonical helper now builds the URI from the path object (forward slashes, drive handled, percent encoded, mode query appended) and all three call sites use it. T2 pins the exact Windows form, proves the naive form was never a URI, proves mode ro survives the canonical form on a live database, and adds a repository gate so the naive construction can never return. After this update the worker log gains one clean start marker and the restart lines stop; the old tracebacks under the previous markers in data alarm-worker.log are the historical confirmation. |
| 2026.07.20-36 | The first field catch of the new diagnostics ring. A Lancaster question about FCU control died with the Anthropic API's own words in the F12 log: tool_result content must be a string, found an object. Root cause: the get maintenance overview tool returned a raw dict instead of a JSON string, so any analysis round that called it (complex questions call several tools in parallel) failed the entire request. Three layers ship: the branch now serialises like every other tool; the loop gains a single funnel guarantee, any non string tool result is coerced to JSON and logged instead of killing the answer; and T19 gains an AST gate that walks every wire facing return in the tool executor and fails the ship if one is not a string, so the next regressed branch is caught at build time, not on site. Receipt: a simulated parallel round with the offending tool now puts a valid JSON string on the wire and the analysis completes. |
| 2026.07.20-35 | Choose the model, per platform and per question. The Admin AI card gains a model row: List models fetches the live catalogue from the Anthropic API with the configured key (no hardcoded list to rot), a manual field accepts any model identifier when listing is unavailable, and Use this model sets the platform default, stored in the database and applied to the next question without a restart. The AI page gains its own picker, Admin default unless overridden, so the same question can be asked to two different models back to back and compared, which is the honest way to get a second opinion without ensemble machinery. The answer and the trace name the model that produced them, the server log prefixes every step with it, and validation rejects malformed identifiers. Receipts: settings round trip, invalid identifier refused, listing without a key and with a rejected key both surface their exact reason (the rejected key receipt is a real round trip to the Anthropic API), the override rides the request body from the page, and T19 pins that the chosen model reaches the wire and that no choice falls back to the platform default. |
| 2026.07.20-34 | The AI grows up: honest errors, a bigger toolbox, saved dashboards, printable reports. Field report: complex questions returned a connection error while simple ones worked. Root cause: each step of the agentic analysis is one API call with a 60 second cap, and a hard question runs many steps over a growing history until one call exceeds the cap; the read timeout was then reported as could not reach the API, which is false, the API was reached and simply not finished. Four answers. One, honesty: a read timeout now says exactly that, names the step it happened on, and points at the remedy (ai.request_timeout_s in config.json, default raised to 180); a true connection failure keeps its own message. Two, visibility: the server prints every AI step (api milliseconds, tools called), the answer carries a machine readable trace shown under the summary, and the browser gains a diagnostics ring: every API call with status and timing, every WebSocket event and every AI step logs as bz lines in F12; type bzlogs() in the console to dump the ring, bzlogs("ai") to filter. Three, reach: the assistant learns the logical building (get_rooms_overview: floors, rooms, members), is told about alarms, maintenance, the semantic model and schedules up front, and may name authored graphics in its answer; hallucinated stems are dropped by validation and named. Four, permanence: Save as dashboard captures an answer (narrative, charts, views, graphics) under a name; the new Dashboards tab lists them per project and re-runs any of them over any from and to window, since every chart fetches its own data for the window by design; Print to PDF renders a branded report frame (name, period, print stamp) through a print stylesheet, graphics embedded with live values and labelled as such. Schema v23 adds the dashboards table; suite T19 pins the migration, the graphics validation and the honest timeout contract; eleven scripted browser assertions cover the tab end to end. The live model conversation itself is desk verified here, no API key in the dev container. |
| 2026.07.20-33 | The floor board opens rooms, not spare parts. Field report from Lancaster: clicking a room on the authored floor graphic landed on the equipment page of the single point clicked (fcu-0502 or room-0502) instead of the room. Since -24 a room is the object and its equipments are its parts, so the read only drill now resolves room membership: any bound value whose equipment belongs to a room opens that room page, on floor boards, on the overview, everywhere; equipments outside any room keep the plain equipment drill, and writable values keep opening the command popup in place. data-nav links resolve membership the same way, and gain two explicit authored targets, room:0502 and floor:05, so a bundle can make a whole floor plan cell one clickable room link. Receipt on the real floor-5 board: the occupancy value of room-0502 opens Room 0502 with its authored graphic; regression gate green. |
| 2026.07.20-32 | Bound values become controls, from the Lancaster room pages. Field report: clicking a light state word on an authored room graphic drilled to the equipment page instead of offering a command, and a burst of self provisioned points froze the console (21 full point reloads back to back in the log). Clicking a bound value now checks the point: writable and operator or above opens the command popup right there, with the point state words as one click commands (the pair from the point itself, RUN and STOP, ON and OFF), the numeric field for setpoints, a Release control action that writes a BACnet NULL to relinquish the command slot, and an Open equipment page link for the old drill; read only values keep drilling exactly as before, so existing bundles lose nothing. The release travels the one audited write path end to end: the setpoint API accepts null, the audit records it, the set topic carries v null (wire receipt), and the bridge writes the BACnet NULL token at its write priority (16, the same slot as its commands). And the reload storm is closed: a burst of unknown point messages coalesces into one trailing reload, receipt six messages one reload. Ten scripted browser assertions on the real Lancaster import, all green; the bridge side of the NULL write is desk verified pending the first live BACnet write test. |
| 2026.07.20-31 | Field fix from the Lancaster rollout: authored room and floor graphics rendered small instead of filling the stage. Root cause: the graphics stage is a centering grid (place-items center), so the overview SVG, being the grid item itself, stretches to the track, while the -30 authored wrapper div shrink fitted as a centered item and collapsed the width 100 percent SVG inside it toward its default replaced size. The wrapper now stretches its track (width 100 percent, justify-self stretch) and the SVG fills it as a block, so room and floor boards render exactly like the overview: full stage width, aspect ratio from the viewBox. |
| 2026.07.20-30 | Room graphics live where rooms live. The Lancaster SVG pack (an overview, twelve floor boards, and thirteen authored views per room, 3016 files) names its graphics after the room, guestroom-0501 and floor-5, while the wire equipments are fcu-0501 and room-0501, so every one of the 3015 non overview files landed in Other views. The room number is now the join key: a stem carrying an existing room number as a complete dash delimited segment binds to that room (the bare number stem becomes the room page graphic, the suffix becomes a named sub view), floor-N binds to its floor with or without zero padding (first stem wins, duplicates stay listed), and everything genuinely unmatched remains an honest extra. The room page renders its authored SVG above the live aggregate with the sub views as a button row; the floor page renders its authored board above the room tiles; both stay live bound. Receipt on the real bundle: 231 of 231 rooms bound with eleven named sub views each, 12 of 12 floors bound, extras zero, all live values updating. The navigation model version bumps to 4, so every install rebuilds its trees at the next start with no reimport. And the Points tree gets its arrow back: every equipment and room row carries the Open in Graphics jump from the pre 28 build, now riding the shared component. Eleven scripted browser assertions on the real Lancaster import, all green. |
| 2026.07.20-29 | The trees come back, and the last two pages join them. Field report: after -28 every object tree except the old Trends one stopped appearing. Root cause, reproduced in a scripted browser run against the live build: the -28 points table surgery deleted the renderPoints helper while the tree mount kept calling it, so loading points died mid flight, taking the Points tree, the Graphics navigator and the runtime defaults with it, invisible to the parse check and to the js_refs gate, whose string collapsing could pair a quote inside a template literal with one in later real code and therefore never saw the calls. Three answers ship together: the stale calls are gone (and the superseded legacy Points tree code with them); the gate is rebuilt as a character level state machine that masks strings, templates, comments and regex literals, names exactly the one missing call on the broken build, and now reads 249 call sites where the old pipeline saw 117; and a second gate is born, tools/ui_smoke.mjs, which boots the real console in a scripted DOM against a live dev server and fails the ship on any runtime error or missing tree. Beyond the fix, the merge deferred twice lands: Trends adopts the shared three lens tree, which scopes a point list below it keeping the click to select rows, series colors, unit chips and per point chart types unchanged (All projects remains available; a specific project is now the default so the tree shows immediately); Graphics moves onto the same component, Overview on top, Other views below, alarm badges on equipment leaves, name search unchanged; Maintenance remounts its hub tree and resets the detail pane on every project switch, closing the stale tree the field log proved; and the tree lens tabs drop their boxed, centered look for the same left aligned underline grammar as every page sub tab. Receipt: twenty one scripted browser assertions green, among them the tree HTML on Points, Trends, Graphics and Maintenance byte identical for the same project. |
| 2026.07.20-28 | The tree grammar from the field screenshots, applied everywhere. Points adopts the Maintenance pattern: a Site (project) select on top, one shared tree below it with the Systems, Logical and Technical tabs first, mounted instantly from the startup built navigation model, and the table drops its tenant and project columns since the selector now carries that context, giving the name column the width it deserved. Clear filters keeps the project scope. Trends aligns its top: the same Site (project) select in the same toolbar grammar sits above its point selection tree and scopes it (All projects remains the default for cross site comparisons), full component merge staying on the roadmap. And the expand triangle no longer wraps above its label: disclosure row buttons render inline beside the marker on every tree. |
| 2026.07.20-27 | One tree, every page, built before the first click. Trees are now part of the starting process: after the persisted caches pre load, every remaining project's navigation model is built synchronously before the server begins answering, with a console line reporting the count and duration, so clients render trees instantly, and an update that reshapes the model (the version discard from -25) rebuilds everything at the next start with no reimport, ever. And the three lens tree (Systems, Logical, Technical) becomes one shared component: Graphics keeps its native version, Points wraps it per project (any node, floor, room, location, system or group, scopes the table to everything beneath it), Maintenance's equipment hub adopts it, and Alarms gains a Place filter: pick any node and the alarm list narrows to that part of the building, with a clearing chip. Trends keeps its point selection tree for one more release, because it selects individual series for charting, a different job that deserves its own careful merge. |
| 2026.07.20-26 | Three lenses over one fleet. The Graphics tree gains permanent view tabs: Systems (the engineering tree as before), Logical (floors and rooms; a floor opens room tiles with live temperatures and a list every point aggregation with inline operator controls), and Technical (equipments grouped by the asset record's location, Roof, B1 plant room, Unassigned trailing, with an inline set location action per equipment). One equipment, two addresses, zero duplication. Room views gain the Upstream strip: the direct confirmed feeders with their live supply and return temperatures, projected from the feeder's own points, appearing automatically wherever the semantic review has confirmed feeds. Asset saves become merge safe (blank fields keep their stored value) and invalidate the navigation cache so location edits appear immediately; the navigation model version bumps to 3, so every install refreshes its trees on update without reimport. Three new navigation tests. |
| 2026.07.20-25 | Floors, and no more reimports for new trees. Field report: the -24 rooms tree did not appear because the persistent navigation cache pre loads at startup and only rebuilt on import, so caches written by older builders kept serving forever. The navigation model now carries a builder version (NAV_VERSION); startup discards mismatched cache files and the background pre warm rebuilds them, so every update refreshes every tree automatically. And the rooms dimension gains the floor tier from the field sketch: room 0502 encodes floor 05, so floors derive from the room number itself (Floor 05 holding rooms 0501 to 0529), short numbers falling back to the digit bucket. Receipt on the real Lancaster EDE: twelve floors, Floor 05 through Floor 18, 231 rooms distributed exactly as the hotel is built. |
| 2026.07.20-24 | Rooms become objects in the navigator, exactly as proposed from the field, and the architectural answer is recorded: nothing changes in the bundle or on the wire, the topic schema stays project/site/equipment/point, and a room is a synthesized view. The navigation model gains a rooms dimension: equipments sharing the trailing room number merge into one node, bucketed by leading digits (room 0xxx), each room opening an aggregated live view of all its equipments with quick operator controls inline (toggle for circuits, steppers for setpoint and fan, through the audited operator write path), a Lighting sub view of circuit tiles, and a per circuit view with its own switch. Per room SVG overrides keep working through the existing equipment stem convention. Receipt on the real Lancaster EDE: 231 rooms in one bucket, functions Air (FCU), Room systems, Lighting with the circuits. Three new navigation tests. |
| 2026.07.20-23 | The Lancaster bundle made whole, diagnosed against the real 3696 row EDE. Root causes: the cloud BACnet server mirrors every point as an analog value, which the EDE importer rightly treats as read only (writable types are outputs), and the mirrors carry blank unit codes, which the room_temp unit gate rejected. Fixes: the importer honours an explicit rw marker in riza1 descriptions for future exports; Admin, Guest room links gains Enable room controls, one audited click turning on writability for exactly the guest vocabulary (sp with setpoint kind, fan-spd, the *-sts circuits) across the project's rooms and companions; the room_temp gate accepts blank units while still vetoing wrong ones; fan-spd joins the fan speed hints. Rooms now span companions: equipments sharing the trailing room number (fcu-0502 plus room-0502, schema v22) merge on one page, so occupancy arrives as presence, the eleven lighting circuits become the Lights page with prettified names, and do not disturb gets its own toggle on Climate. Receipt on the real file: 231 rooms, companion paired, headline rm-temp, setpoint clamped, fan role, presence, nine circuits, DND. Six new engine tests. |
| 2026.07.20-22 | Room pages become the full guest remote, per the GRMS reference: a fixed tab bar at the bottom of the phone screen switches between Climate, Lights and Curtains, each tab appearing only when the room actually has such points. Climate gains a fan speed stepper (new fan_speed role, placed before setpoint so fan sp names cannot be stolen, 0 to 3 clamped). Lights and curtains are multi instance, so they collect by name over the room's writable points (light, lamp, dali; curtain, blind, shade), rendering a toggle for switches and a slider for percent dimmers. Every control travels the single audited guest write path with a server side clamp per kind: comfort band for the setpoint, 0 to 3 for the fan, 0 or 1 for switches, 0 to 100 for dimmers, and a point must belong to the room to be writable from its token. Three new engine tests cover collection and the clamps. Restored the working tree from the delivered package after a mid session container reset, receipts intact. |
| 2026.07.20-21 | Field fix from a phone screenshot: a room page headlined Room temperature while showing 11.9 g/kg, the platform's own calculated absolute humidity point, which shares the source sensor's id stem and won the new room_temp role by token match. Two guards close the class: derived (calc) points never claim roles at all, and a role's unit must agree with its meaning (temperature roles accept degrees or blank, rh needs a percent, co2 needs ppm), enforced in the guesser's fitness check; confirmed semantic review overrides remain untouched. The guest headline additionally refuses any non temperature unit and falls through to the next temperature role, so even stale role data cannot mislabel the big number. Two new tests reproduce the exact field case. |
| 2026.07.20-21 | Room headline fixed from a field screenshot: a derived absolute humidity point named after its source (Room Temperature Absolute Humidity, unit g/kg) was claiming the room_temp role by name, because the -20 vocabulary extension added roles without teaching the unit plausibility gate about them. The gate now covers all four: room_temp must carry a temperature unit, rh a percent unit, co2 a ppm unit (blank units stay allowed for sites that never filled them), and occupancy must not be a temperature. The headline also names its source honestly: Room temperature when it comes from the room or return sensor, Supply air temperature when only that exists. One new test pins the g/kg case. |
| 2026.07.20-20 | Room pages learn humidity, CO2 and presence, with a per project switchboard. Root cause first: the role vocabulary had no rh, co2, room_temp or occupancy at all, which is why room pages showed temperature (via the return temperature fallback) and nothing else even when the points existed. The vocabulary gains all four with careful hint ordering (room temperature sits after supply and return so duct sensors keep their roles), the semantic review can override any guess as always, and the guest view model reads them. On top, the switchboard: a project level decision (schema v21) choosing whether room pages display humidity, CO2 and presence, applying to every room of the project, edited via three checkboxes in Admin, Guest room links; temperatures always show. Presence renders as Occupied or Empty. Two new engine tests cover the switchboard. |
| 2026.07.20-19 | Guest room links learn their public name. QR codes previously encoded the address of the browser session that rendered them, so an Admin opened on localhost would print QRs no phone can reach. A new config key, guest.public_base_url, makes the server return absolute room links (for example https://bms.projects.beez-fm.com/g/{token}); the Admin card, the QR codes and the printable sheet all use it, and a hint under the table explains the setting. Without the key, behaviour is unchanged: links stay relative and QRs use the current browser origin, correct for single host setups. |
| 2026.07.20-18 | Guest room phone views, one platform, straight to the phone. Rooms derive automatically at import from the equipment list (room serving classes or a room temperature role; schema v20), each with a per room token whose QR code and NFC tag carry the same link and whose regeneration revokes every copy. The mobile page at /g/{token} generates itself from the role map with zero authoring: headline temperature, a clamped setpoint control on the platform's single audited write path signed guest:{room} (comfort band configurable, default 20 to 26, one change per two seconds), and the readings. Admin gains the Guest room links card: generate, per room QR, regenerate, enable or disable, and a printable QR sheet for the hotel handout. Six new engine tests (T19) cover room derivation, the view model and the clamp. The QR encoder is vendored offline like the chart library. |
| 2026.07.20-17 | Auto tasks survive a dead worker. Field chain diagnosed: the alarm worker on site was crash looping (its traceback lands in data/alarm-worker.log, where the supervisor writes the process output), so no live snapshots were published, so the reconciler saw an empty world and correctly created nothing, while the alarms page kept showing findings through its on-demand fallback. The reconciler now walks every project and, when a project has no fresh live snapshot, evaluates the basic rules itself exactly like the alarms page fallback, mapping findings to deterministic keys so the no-duplicates guarantee holds; in fallback mode it creates tasks but never auto closes, because on-demand findings lack the worker's debounce and a machine should not close work on flappy evidence. The console line names the mode, so fallback operation is visible. |
| 2026.07.20-16 | Auto tasks now actually fire. Root cause from the field: the reconciler created tasks for severity critical, but the worker's real vocabulary is info, warning, fault and (once, for comm loss) critical, so nothing ever matched and the silent exception handling never said so. The criterion now matches reality: sensor faults (virt:) and anomalies (anom:) always earn a task per the triggered by values and predictions requirement, severities fault and critical earn one, plain warnings and info do not, and meta alarms (communications, flood, chatter) never do because they are channel events, not equipment work. Both lists are configurable (maintenance.auto_task_severities and auto_task_prefixes in config.json). The reconciler also gains a voice: a console line whenever it opens or closes tasks, and errors are printed instead of swallowed. The manual Task button on live alarms was always unaffected. |
| 2026.07.20-15 | One toolbar grammar across the whole product, from four side by side field screenshots (Points, Graphics, Twin, Schedules) that made the drift visible. Every toolbar now shares the bottom baseline, every select and text input the same 32 px geometry and radius (the oversized Points search pill included), stacked small labels above their control work on every page instead of only the tc bar ones, trailing status text sits optically centred on the control row, and the two button tiers are consistent everywhere: btn secondary stays the green outlined action, and btn ghost inside toolbars becomes a quiet bordered button instead of invisible bare text (Clear filters and Copy diagnostics now read as buttons). The Trends only alignment override is gone. |
| 2026.07.20-14 | Maintenance sub tabs now truly match Bus systems and Admin: the shared design lives on the subnav wrapper class (flex row, bottom rule, underline active state), which the maintenance nav lacked, so it rendered as unstyled browser buttons; it now carries the class, sits on its own row under the toolbar exactly like the other two, and the conflicting one off rule from -13 is removed. One pattern, three pages, no exceptions. |
| 2026.07.20-13 | Field fix and SAUTER alignment. The equipment hub 500 is fixed: the maintenance log column is equipment_id and both the history query and the task completion insert now use it (completing tasks was equally affected). The client error banner is honest: a genuine server error reports its status and body with a pointer to the server console, and the proxy or VPN suspicion is raised only for gateway style responses. Against the SAUTER SVC maintenance reference supplied as the target: the asset record gains date of production, inspected on and controller (schema v19), completing the equipment field set; plans as scheduler, events and value triggers, tickets with assignee, open, pending and closed states, and files per equipment were already covered. Named for the next maintenance release from the same reference: ticket comments (the logbook), files and linked tickets per ticket, a visible ticket history, and notification channels per ticket event starting with e-mail. The maintenance hub tree gains full design parity with the other pages. |
| 2026.07.20-12 | The maintenance hub. Five sub tabs (Equipment, Tasks, Recurring plans, Documents, Reports); the Equipment hub pairs the architecture tree with a one card summary per equipment: editable asset record (brand, model, serial, install date, location, notes; schema v18), recurring plans, open work, documents, all points with live values, last executed maintenance and recent history. Plan templates widen to fans, heat exchangers, valves and dampers, with the yearly inspection fallback guaranteeing every equipment a plan at import. Sensor faults and critical equipment alarms maintain themselves into tasks through a five minute reconciler (suppressed consequences excluded, no duplicates), and a cleared condition auto closes only tasks automation opened and nobody touched, recorded as automatic; human started work is never closed by a machine. Completing an alarm born task acknowledges its alarm, signed maintenance. Three new engine tests cover the reconciler. |
| 2026.07.20-11 | Maintenance, the CMMS core: schema v17 (recurring plans, tasks with assignee and proof, equipment documents). Plans generate automatically from the equipment list and Brick classes with market standard intervals, at import and on demand, idempotently; an hourly scheduler opens a task for every due plan and completion rolls the plan forward from the completion date. Tasks carry origin (alarm, plan, value, prediction, manual), an assignee who may be a viewer (execution rights follow the assignment; assigning needs operator), and an enforced proof workflow: before and after photos or no done, blocked with a reason for the missing part, cancelled with a note when repair is not the answer. A Task button on every live alarm creates an attributed task in two clicks, with anomaly and prediction findings mapping to their origins. Done tasks feed the maintenance log, and with it Explain and the label factory, automatically. The new Maintenance tab holds tasks, plans, per equipment documents, and the executed maintenance report between any two dates with CSV export; the AI gains get_maintenance_overview and instructions to weigh maintenance in every analysis. Twelve new engine tests cover templates, idempotent generation, the state machine with photo enforcement, plan rolling and report aggregation. |
| 2026.07.20-10 | The navigation cache goes persistent, closing the last non-instant path. The in-memory server cache from -6 answered in 5 to 8 ms but was lost at every service restart (so, at every update), making the first tree open per project pay the cold build again. The model now persists to disk (data/gfx-nav/{project}.json) whenever built, pre-loads into memory at startup before the first request arrives, pre-warms any project without a persisted file in a background task, rebuilds fresh synchronously at bundle import so the post-import fetch is instant and already correct, and removes its file at project deletion. Net effect: the object tree is a memory hit from the first click after any restart, exactly the reconstruct-only-at-restart-or-import behaviour requested from the field. Ship discipline note: the asset stamps and the APP_BUILD constant bump together with VERSION.txt at every release. |
| 2026.07.20-9 | The pictures bug, root cause found and fixed, with the tooling that ends the hunt class. The 2026.07.20-4 navigator rewrite replaced a code span that contained the fetchGfx helper while the new code kept calling it, so every graphic fetch (overview, authored boards, equipment mimics) died as a runtime ReferenceError, invisible to the parse check and the Python suites; field diagnosis had meanwhile proven the server, files, import and authentication clean, which cornered the fault in the page. fetchGfx is restored, instrumented, and hardened with a guarded legacy fallback. A new ship gate, tools/js_refs.py, extracts every defined name and every contextual call site from app.js and fails the release when a called name has no definition, which catches the deleted helper class cold. And the Graphics tab gains the diagnostics the field asked for: a build stamp compared against the server version with an explicit stale browser verdict, a rolling client log of every navigation and fetch with outcomes and first bytes plus all JavaScript errors, and a Copy diagnostics button whose report includes three live probes and an active injection self test that fetches the overview, injects it, and states whether an SVG element materialised and at what rendered size. |
| 2026.07.20-8 | Stale asset elimination, after field reports of pictures and tiles not appearing that the server survived twice: the cache invalidation on import was verified correct by reproduction and inspection, and the graphics endpoints serve raw SVG, leaving stale browser assets (an old app.js against a new index.html) as the one class explaining every symptom at once. The page now stamps its stylesheet, chart vendor and application script with the release version, so every update forces every browser to fetch the new frontend with no manual hard refresh; the stamp is bumped with VERSION.txt at every release as ship discipline. Entering the Graphics tab quietly re-fetches the navigation model (single digit milliseconds from the server cache), so a bundle import in another tab or session can never leave the tree stale. And the Graphics toolbar now carries a plain status line after every load (overview present or not, mimic, system and other view counts), so any remaining discrepancy report arrives as data. |
| 2026.07.20-7 | Import card clarity. Reproduced against a live instance carrying two projects under one tenant: the server lists both everywhere, and the confusion was the import card itself, which never offers a project choice because the bundle's own project slug picks the target. The card now says so: under the tenant select it lists that tenant's existing projects and states the rule plainly (a bundle whose slug matches an existing project re-imports into it, updating points and refreshing graphics; any other slug creates a new project beside them). After a successful import the page refreshes its own lists (points, tenant and project selects, the explainer), so a newly created second project appears immediately in every selector without reloading the browser. |
| 2026.07.20-6 | Navigator polish from first field use. Breadcrumbs became the tree path instead of the click history: each view's trail is derived fresh from its ancestry in the navigation model, so moving between sibling units replaces the tail rather than stacking views one behind another, and every crumb is a real jump target. The navigation model is cached on the server, built once per project on first request and invalidated only by bundle import, project deletion or restart; measured on the real Hyatt Regency Danang project, the cold build takes about 80 ms and cached serves answer in 5 to 8 ms. The client rebuilds the Points and Graphics trees only when the visible project set changes, never on incidental live refreshes, so open branches survive streaming data. A project without graphics files now says so plainly on every equipment panel and names the one step remedy (import the bundle; a re-import refreshes graphics without touching data). |
| 2026.07.20-5 | The Points navigator: the architecture tree from the Graphics tab now organizes the point list too. A sidebar on the Points tab expands each project lazily into the same systems, groups and equipment model; clicking any node scopes the table to exactly that selection, shown as a filter chip with equipment count and one click clear, while text search, column filters and group-by keep working inside the scope. Every equipment row carries a jump arrow that opens that equipment directly in the Graphics tab, making the list and the pictures two views of one navigation. Graphics serving was verified end to end against the real Hyatt Regency Danang bundle, including a re-import over the existing project refreshing all 396 SVGs with zero warnings: a project showing only live panels has an empty graphics folder, and one bundle re-import fills it. |
| 2026.07.20-4 | The Graphics navigator, built against a real 396 mimic hotel bundle. The flat equipment dropdown is replaced by project selection, a main overview view, and hierarchical drill-down following the architecture the equipment names already encode: a sidebar tree with one branch per system, progressive grouping by name segments (numeric device blocks bucket by leading digit, singleton unit runs chunk into ranges, no flat list longer than 24), a search box, and breadcrumbs that jump back anywhere. Groups render as tile boards with mimic indicators and live alarm badges, or as an authored board when the bundle ships an SVG whose stem equals the group key, with a toggle between the two. SVGs are click-navigable: data-nav links any element to an equipment, group or view, and every data-path bound value on overview and board pages is automatically clickable through to its equipment, so every existing bundle gains drill-down with zero rework and no bundle format change. Equipment pages gain sibling chips for quick moves inside a group, the Ask AI embed context follows the navigation trail, and the model is served by GET /api/projects/{id}/gfx-nav from a pure, tested builder (eleven new tests on the real site's naming shapes). Verified end to end in development by importing the Hyatt Regency Danang bundle and reading the navigation model back from the live endpoint. |
| 2026.07.20-3 | Multi-project user scope, schema v16. Users were either tenant-wide or locked to exactly one project; now a user can carry an explicit set of project memberships (user_projects), so one login can hold a whole portfolio of buildings or any subset, while nothing selected keeps the classic tenant-wide behaviour and existing single-project users migrate automatically. The membership set is resolved from the database on every authenticated request, websocket and export tickets included, so the database is the single source of truth and a scope change revokes the target's sessions through the same token generation bump a role change uses. Every guard and listing filters through one pure helper (an empty set fails closed), the Create user card takes a multi-select, each user row gains an inline scope editor, and PUT /api/users/{id}/scope replaces memberships with full tenant validation and auditing. Deleting a project now removes only that membership and deletes only users whose sole scope it was, matching the old semantics exactly. Project slugs stay globally unique on purpose: the slug is the riza1 site-id and the first MQTT topic segment, so the grouping construct remains the tenant, with this release adding the missing middle ground inside it. Nine new tests cover the isolation rules. |
| 2026.07.20-2 | Virtual sensors and model-based sensor FDD, the schema's Section 8.2 and the last pre-autonomy diagnostics layer. Nightly, on the very hourly rows the baseline builder already reads, the worker fits robust Theil-Sen lines for every relationship the semantic layer proved: supply temperature from return temperature, from actuator position and from amps on the same equipment, and downstream supply from its operator-confirmed upstream feeder. Fits survive only with at least 72 paired hours, input variation and real skill (residual spread clearly narrower than the signal); everything else is dropped with its reason logged. Live, every minute, each modelled reading is Z-scored against its fresh independent estimates with family noise floors, and the deliberately univariate design buys discrimination: a reading contradicted by two or more estimates in the same direction names the sensor as the likely liar at warning severity; a reading vouched for by one estimate while another disagrees blames that estimate's input instead; a single estimate, or estimates split in direction, stays an info-grade disagreement that claims no verdict. Findings carry virt: keys with anomaly-grade debounce (600 in, 900 out), flow the full ISA-18.2 lifecycle, appear in Explain timelines automatically, and become labelled sensor faults when logged against in the maintenance card. Knobs alarm_worker.virtual_enabled (default on) and virtual_sigma (default 5); the worker heartbeat reports fitted model counts. Sixteen new tests cover spec generation from semantics only, the fitting gates, and every discrimination verdict including the honest ones. |
| 2026.07.20-1 | The Guideline 36 layer, the semantic graph paying rent in the alarm list. Hierarchical alarm suppression: when an equipment is in hard alarm, findings on everything downstream of it via operator-confirmed feeds relationships are annotated as suppressed, keeping their full ISA-18.2 lifecycle, severity and events while the live list dims them behind a suppressed pill with a hide toggle and the snapshot counts name them; meta alarms are never suppressed, anomalies can be suppressed but never suppress, a suppressed alarm never suppresses others so chains point at the root, and the upstream must have raised on an earlier pass so causes lead by construction; sites with no confirmed relationships see no change, and alarm_worker.g36_suppression turns it off. Trim and Respond advisor: for every supervisory reset loop the review kept, counts requesting member actuators (90 percent or more, fresh values only), applies the G36 arithmetic (2 ignores, 0.2 K trim, 0.3 K respond per excess request, capped at 3) and clamps every target inside the setpoint's own observed 30 day operating range, so it can never recommend a value the plant has not lived at; advisory only, the platform never writes, served on /api/g36/advisor with a card beside the loop review. Sixteen new tests cover the topology under cycles, the suppression exclusions and root-cause chaining, and the advisor arithmetic with freshness gates, direction inversion and range clamping. |
| 2026.07.19-21 | The semantic schema alignment (appendix 10), schema v15, everything derived at read time so existing projects need no migration. Systems: the topic path's second segment is a system category under the platform's own convention, so the graph now types these groupings as brick:System collections with rec:includes membership, correcting the earlier brick:Site claim. Controllers: the importer now persists each point's hosting BACnet device instance (the EDE always carried it), and the graph grows brick:Controller nodes with brick:hosts edges; a plain re-import of a project's original EDE backfills older projects through the import's normal upsert. Control loops: a CTRLont layer proposed by logic, on the pattern the role guesser and Brick tagger already proved: temperature cooling and heating actors (sensor, setpoint, actuator, the two sides separate but sharing members), start_stop actors from command plus proof, zone loops through a stated temperature fallback, and supervisory reset candidates from global setpoints at low confidence with the honesty that a point list cannot prove a sequence; the loop's internal logic is never inferred and stays unknown until a sequence document fills it. A Control loops review table in the Digital twin card confirms or dismisses each proposal in one click (loop_reviews stores only the verdicts); dismissed loops leave the graph and every export, confirmed and proposed ones are SPARQL-queryable as ctrl:ControlActor and ride the Turtle export with new rec and ctrl prefixes. The loop tests caught and fixed a live role-guesser bug on the way (a heating hint substring-matching inside chilled water valve names). Nineteen new tests cover the inference, the honesty labels, review merging, and all three graph layers end to end. |
| 2026.07.19-20 | Phase 4: generative UI and conversational analytics, delivered as a declarative contract instead of a frontend framework, keeping the platform dependency-free and build-free. The AI may now return a views array chosen from a strict vocabulary: charts (line, bar, stacked, area, scatter, pie, doughnut) whose point-referenced series the platform fetches as real historian data (inline series only for model-computed values such as rankings), with second axes and threshold lines; stat card rows with warning limits; data tables; structured text; hand-drawn SVG schematics; and follow-up question chips that continue the conversation in one click. Everything passes server-side schema validation, capped and dropped with visible warnings where it cannot be verified, and SVG passes a whitelist sanitizer twice (server and browser): scripts, event handlers, styles, foreignObject, external references, javascript and data URLs and entity declarations never reach the DOM, while shapes, paths, text, gradients and markers pass, enough to draw a plant schematic in the brand palette. The print report carries every view kind, each on its own page. Hybrid co-existence: an Ask AI panel now lives on the Trends page, telling the model exactly which points the operator has selected, and on the Graphics page, naming the equipment on screen, both rendering full generative views inline beside the manual interfaces. Twenty-six new tests cover the sanitizer under attack and the validator's schema discipline. No schema change. |
| 2026.07.19-19 | Five quality upgrades across the AI, Alarms and Bus pages. AI answers render structured now: numbered steps and dash bullets each on their own line and bold honoured, applied to the AI tab and the Explain card alike, with the model instructed to write that way. The AI answers broad advisory questions properly: get_energy_overview integrates every power and energy point to kWh over the analysis window, ranked with equipment attribution, get_operations_overview delivers active alarms, KPIs, drift with forecasts, worst anomalies and recent maintenance in one call, the reasoning budget doubled to twelve rounds, and the standing instruction is evidence first, then prioritized recommendations tied to it and honest about expected impact. The Alarms tab gained an Alarm history card: raises per day stacked by severity, hours in alarm per day from properly paired episodes (a raise matched to its return to normal; shelving never splits an episode; still-open episodes count to now and say so), and the ten alarms longest in alarm over a 7, 30 or 90 day window, all from the transition history. Bus Systems gained an always-on telegrams-per-second health chart, five minutes deep with now, average and peak chips, storm highlighting and a top-talkers row by topic head, costing two dictionary writes per message on the hot path. And AI analyses print: a branded, print-clean report with the question and structured analysis first and every chart on its own page, nothing cut in the middle, ready for save-as-PDF. Ten new tests cover the episode pairing semantics and the traffic ring's counting, pruning and summaries. No schema change. |
| 2026.07.19-18 | Phase 3: semantic bootstrapping, the knowledge graph. The digital twin is now a queryable RDF graph, built on demand from the same twin assembly the endpoints use so it can never drift stale: equipment typed with Brick classes, feeds topology, hasPoint to typed point nodes carrying their unit, kind and beez:dbId (the numeric id the historian tools take), and derived Site nodes with hasPart. Two engines behind one front door: a dependency-free built-in engine answers SELECT over basic graph patterns with PREFIX resolution by namespace, the a shorthand, literals and LIMIT, on every install unconditionally, and refuses FILTER, OPTIONAL and friends by saying exactly that; installing rdflib (optional line in requirements.txt, one pip install in the venv, needed because the in-browser updater extracts and restarts without running pip) upgrades the same front door to full SPARQL 1.1, and every answer names the engine that produced it. The AI tab now follows the specification's discipline as behavior, not aspiration: a query_knowledge_graph tool sits first in its toolbox with a standing instruction to ground topology, classes and point ids in the graph before touching the historian, alongside the existing get_equipment_context passport tool. Humans get the same engine through a SPARQL box in the Digital twin card and POST /api/graph/query, admin and audited. Twenty-two new tests cover the triple builder including derived sites, the built-in engine's parsing, matching, joins across hops, literal handling, prefix resolution, LIMIT and honest refusals, and row-for-row parity between the built-in engine and rdflib on the core query set. No schema change. |
| 2026.07.19-17 | Documentation and Phase 2 of the AI-native roadmap. First, the guide gained a dedicated Alarms reference page, linked from every guide page: the complete account of how alarming works, the worker architecture and its read-only discipline, the ISA-18.2 lifecycle with the full state and debounce tables, every detection layer with its rules, severities and key shapes, the communications hold, flood collapse and chattering mechanics, the equipment roles and all five contradiction checks, the statistical layer with its guard rails and forecasts, the label factory, relationship mining and Explain, plus complete configuration, API and MQTT topic references and the honest operational limits. Second, the automated digital twin: every equipment now carries a dynamic machine passport holding its Brick class, every point with its own Brick class, unit and wire endpoint, the role map, confirmed feeds relationships in both directions, and the monitors the platform actually runs on that asset, derived truthfully from roles and point classes. Tagging is conservative: a curated Brick subset, evidence required, ambiguity walking up to an honest parent class rather than sideways to a wrong leaf, and every tag overridable in the new Digital twin card under Admin, Alarms with manual decisions never overridden (schema v14: point_tags, equip_profiles). The twin exports as Brick Turtle or JSON and imports models in the same simple dialect to seed classes and feeds links, superadmin only and audited, with the parser limitation stated plainly. The AI tab gained a get_equipment_context tool that grounds the model in the physical layout before it calculates. Eighteen new tests cover the tagging on realistic fixtures, evidence promotion, truthful monitor wiring, manual overrides, endpoints, and the full Turtle round trip including hand-written models. |
| 2026.07.19-16 | Tier 3: cause, memory, and honest prediction. The maintenance and observation log (schema v13, maintenance_log) is the label factory: a Log button on every alarm row and an Add observation button open a twenty second form with a fixed cause vocabulary, a note and the action taken, and each entry can bind to the exact alarm activation it closes (key plus raise timestamp), which is the join supervised failure prediction will need in two to three years; entries are audited and listed on the tab. The drift detectors now forecast: a delta-T decline or amps creep that would cross its practical limit (1.5 K, or 20% over the running median) within 26 weeks says so in weeks, plain regression on the KPI, silent beyond that horizon because extrapolation stops deserving belief. Coincidence mining over the raise history (alarm_events gained an equipment column, backfilled for old rows) proposes equipment relationships with direction and typical lead time under honest thresholds (five co-occurrences minimum, 0.3 of the rarer equipment's raises, meta alarms excluded); admins confirm or dismiss, decided pairs stop being proposed, and confirmed links (equip_links) seed the topology graph for future Guideline 36 suppression. The Explain button on any live alarm assembles a deterministic evidence bundle (roles with fresh values marked fresh and stale marked stale, the surrounding timeline, confirmed links, maintenance history, active drift) and asks the configured Anthropic model for at most three ranked hypotheses tied to specific evidence with concrete checks, the bundle shown beneath the answer; needs the API key under Admin, AI. Seventeen new tests cover the mining thresholds and direction, the bundle content including honest staleness marking, the label round trip, the v13 migration, and both forecasts. |
| 2026.07.19-15 | Tier 2: the label-free anomaly layer, statistical and honest about what it knows. Baseline deviations: every numeric sensor gets a robust profile per workday-or-weekend and hour-of-day bucket (median and MAD spread of its per-hour means over 28 days, rebuilt nightly from the project's own history file), and a fresh live value scoring beyond six robust spreads raises an info alarm in a new anomaly category, debounced ten minutes to raise and fifteen to clear. Guard rails everywhere: per-family spread floors so naturally flat points cannot alarm on noise, twelve samples minimum per bucket, stale values never score, and setpoints, binary states and alarm-flag points are never baselined. Flatline detection: fresh timestamps with a value frozen six hours on a point whose own baseline proves it normally varies is a frozen sensor, raised as a sensor fault, cleanly distinguished from comms loss (the -13 hold) and stale data (the stale check). Drift detection with Theil-Sen slopes, where one outlier day cannot bend the line: declining water delta-T across circuits the equipment roles already know (fouling and bypass signature, water-only by design) and creeping running current on motors, both requiring fourteen days and reporting in plain units per week. Anomalies ride the same machinery as everything else: ISA-18.2 lifecycle, comm-loss hold, flood grouping, ack and shelve, history and KPIs. All statistics live in the worker, read-only as always; installs on the InfluxDB historian skip the layer gracefully for now. Tuning in config.json under alarm_worker (anomaly_enabled, anomaly_sigma, flat_hours, baseline_days). Twenty-eight new tests cover the robust statistics, baseline refusal on thin evidence, the floors, flatline logic, both drift detectors, and the full path from history file to debounced wire event. No schema change. |
| 2026.07.19-14 | Tier 1 complete: the equipment logic pack. Five APAR-inspired contradiction checks now run continuously per equipment, each naming a physical impossibility: heating and cooling commanded together (both above 10%, with binary commands understood); commanded on but the run status disagrees; running on near-zero current (below 0.5 A); a valve saturated above 95% that still cannot bring its supply within 2 K of setpoint, direction aware on both the cooling and heating side; and low water delta-T, under 2 K across a chilled or hot water circuit at 80% plus load, restricted to water circuits on purpose because air-side delta-T has economizer physics a flat check must not pretend to understand. The checks resolve which point plays which part (cooling valve, run status, amps, supply and return temperature, setpoint and so on) by conservative name-based guessing, one point per role, ambiguity left unassigned; a new Equipment logic roles editor under Admin, Alarms shows every slot with its provenance (auto, manual, unassigned), lets an admin pin any slot to a point, disable a role outright for an equipment, or revert to auto, all audited, with the live engine reloading instantly on every change (schema v12, equip_roles). Checks only ever run on fresh evidence: any point older than 15 minutes means no finding rather than a guess, and the comm-loss hold from -13 already freezes everything when the whole link is gone. Findings carry warning severity, the equipment category, stable keys per equipment and check, and the full ISA-18.2 lifecycle; thirty new engine and integration tests cover the guessing, every check firing and staying quiet, direction awareness, the water-only restriction, freshness, and manual overrides end to end. |
| 2026.07.19-13 | Tier 1 of the alarm roadmap: the quality pack. Communications loss suppression: when every bridge reporting for a project is failing or silent for three minutes, the worker raises exactly one critical site communications alarm (debounced a full minute so one bad poll cycle never pages anyone) and holds every other alarm for that project in place; held alarms keep their lifecycle state through the outage, nothing false-clears on frozen data, no new alarms raise on stale evidence, and every debounce window restarts when the link returns, so the platform re-establishes the truth instead of guessing it. Flood collapse: five or more active alarms on one equipment (config alarm_worker.flood_n) become one parent warning naming the count and the first out, the earliest raiser, and the alarm table folds the grouped rows beneath the parent with a toggle. Chattering detection: any alarm that raised ten or more times in 24 hours (config alarm_worker.chatter_n) gets a calm info alarm naming it a bad actor, computed from the event history on the worker reload cadence, with hysteresis (clears below six) so the detector itself cannot chatter; meta alarms never breed meta alarms. A new Alarm quality panel on the Alarms tab scores the system the ISA-18.2 way: annunciations per day against the roughly 150 per operator per day that is workable, peak hour, percentage of hours in flood (ten plus raises in an hour), median acknowledgement latency, standing alarms older than 24 hours, and the top ten bad actors of the window, from GET /api/alarms/kpi. Fourteen new integration tests cover the hold semantics, first-out naming, and chattering hysteresis. No schema change. |
| 2026.07.19-12 | Hotfix: the server failed to start on 2026.07.19-10 and -11 with NameError: name 'threading' is not defined, raised the moment the lifespan constructed the alarm worker supervisor. The supervisor's monitor thread and stop event use the threading module, and main.py never imported it; the release gates (syntax compile and a module import smoke test) could not catch it because the class only instantiates at real startup, after the module is already imported. The import is added, and the release discipline gained two permanent gates so this whole class of failure is caught before shipping: a pyflakes undefined-name scan across every runtime module (app, worker, bridge, the EDE importer), which now reports zero, and a runtime instantiation smoke test that constructs the exact supervisor object the lifespan constructs. No schema change, no behaviour change beyond starting. Anyone stuck on -10 or -11: either update with this package via update.ps1 (the in-browser updater needs a running server, which is exactly what the bug prevents), or as a one line emergency repair add "import threading" beneath "import sys" near the top of app/main.py and start the server again. |
| 2026.07.19-11 | The live BACnet browser, and a Bus Systems fix. The BACnet browser sub-page now browses for real through the platform's own bridges, from the server's network vantage: Who-Is sweeps (optionally bounded to an instance range), manual add by address and instance for routed devices that ignore broadcasts, device identity, full object lists, on-demand reads of any object's name, present value and unit, and a complete EDE 2.3 export with live per-object progress. The export is deliberately shaped so the platform's own importer reads it back verbatim, closing the onboarding loop for a brand-new site: configure a bridge, start it with no projects connected (a bridge now starts fine source-less and idles as a pure discovery instrument, where it previously refused), Who-Is, browse, export EDE, import as a project, connect the project to that bridge. Every browse action is a job: the request returns immediately, the bridge answers over MQTT ($beez/browse-cmd, browse-result, browse-progress), the page polls, and one lock inside the bridge serializes browse work so a long scan can never trample polling or COV traffic; actions are audited. The desktop beez-BACnetBrowser exe stays exactly where it was, as the field instrument for laptop subnets and RS-485. Fixed: the Bus traffic bridge dropdown was always empty because the page iterated the response envelope instead of the bridge list inside it and the error was swallowed silently; the bridge status list endpoint is also admin now instead of superadmin, matching the capture page that needs it. New APIs: POST /api/browser/start, GET /api/browser/job/{id}, GET /api/browser/job/{id}/ede. |
| 2026.07.19-10 | Phase 1a of the AI-Native roadmap: continuous alarming with the ISA-18.2 lifecycle. A supervised worker process (worker/alarm_worker.py, spawned and restarted with backoff by the platform, logged to data/alarm-worker.log) now watches the live MQTT stream and evaluates the exact same engines the on-demand endpoints use, basic rules, sensor plausibility, advanced rules and the bridge-polled device alarms together, every 10 seconds by default, with raise and clear debounce (30 s to raise, 60 s to clear, both tunable in config.json under alarm_worker) so alarms cannot flap. Each alarm carries the ISA-18.2 lifecycle: Active Unacknowledged (blinking UNACK chip), Acknowledged (steady, with the operator name), Return-to-Normal Unacknowledged (a transient fault latches until someone acknowledges it instead of vanishing overnight), and Shelved with a hard expiry of 30 minutes to 7 days, evaluated silently and re-raising unacknowledged if still active when the shelf ends. Acknowledge and shelve are audited operator actions on the Alarms tab, persisted (alarm_acks, alarm_shelved) and honoured across worker restarts, which restore lifecycle positions from the worker's own retained MQTT snapshot and reconcile against the database. Every transition lands in an append-only history (alarm_events). The worker never writes the database: it reads it strictly read-only and publishes transitions over MQTT ($beez/alarm-events, retained $beez/alarm-state snapshots, a retained heartbeat on $beez/worker-status) for the main application to persist, preserving the single-writer discipline; operator commands travel back on $beez/alarm-cmd. The Alarms tab is now a live view polling every 5 seconds while open, with lifecycle chips and per-row Ack, Shelve and Unshelve buttons for operators, shelved rows dimmed and excluded from the severity card counts; if the worker is offline or disabled the tab falls back to the on-demand snapshot evaluation and says so. New APIs: GET /api/alarms/live, POST /api/alarms/ack, POST /api/alarms/shelve, POST /api/alarms/unshelve, GET /api/alarms/events; creating or deleting an advanced rule publishes a reload hint so the worker picks it up instantly rather than on its 60 second reload. One honest v1 limit: the worker follows the canonical five-segment topic convention, so projects fed through an explicit topic-prefix map keep on-demand evaluation for now. Schema is now v11. |
| 2026.07.19-9 | Hotfix: the Bus Systems page rendered empty. The tab switcher still carried a hardcoded list of panel names from before the tab existed, so selecting Bus Systems hid every known panel and never revealed the new one; its content even loaded, invisibly. The switcher now derives the panel set from the DOM (every top-level panel is a main element with the panel class and a panel-NAME id), so a newly added tab can never again exist in the navigation while its panel stays permanently hidden. No server-side changes. |
| 2026.07.19-8 | The BACnet browser sub-page (Bus Systems) is now the home of beez-BACnetBrowser, the desktop field instrument. The tool deliberately stays a single portable Windows exe, because it binds the field laptop's own network cards and COM ports for Who-Is discovery, object browsing, property writes and EDE 2.3 export, which no web page can do; the platform integrates it by distributing it. A superadmin uploads the published exe once per tool release (POST /api/browser-tool), stored in tools-dist/ beside the app where platform updates never touch it, content-checked for a Windows executable header, checksummed with SHA-256 and audited; any admin sees the current release with size, date and checksum, and downloads it through GET /api/browser-tool/download, also audited. The page carries the field notes that matter on site: the Windows Firewall prompt on the first Who-Is, never sharing a machine with a soft BACnet server on UDP 47808, adding devices on routed subnets by IP, and MS/TP attachment through a USB RS-485 adapter. The browser's EDE exports feed straight into bundle onboarding on this platform. |
| 2026.07.19-7 | Points page usability and a new admin Bus Systems tab. The Points table now shows a COV column fed by the bridge's status feed: per point, blue means the device accepted the subscription and its task is alive, green is the definitive confirmation (notifications actually flowing), amber means a previously accepted subscription lapsed and the heartbeat loop will renew it, red means the subscribe call was refused (reason in the tooltip), hollow means no COV state applies. Rows are lightly tinted by kind (green sensors, blue setpoints, violet statuses); every header sorts on click; tenant, project, site, equipment, point and kind carry Excel-style per-column value filters with All/None and a Clear filters reset; and Group by folds the list into collapsible groups by tenant, project, site, equipment, point or kind. The old three toolbar dropdowns are gone, superseded by the header filters. The new Bus Systems tab (admins only) carries a Bus traffic page, a Wireshark-flavoured live window onto the platform's own conversations: broker messages plus the BACnet requests, responses, writes and COV notifications the selected bridge exchanges, mirrored over $beez/capture/<name> only while the page is watching, with live statistics (total, messages per second, failures, top 10 controllers with failure counts, top 10 points). Retention is zero by design: a bounded in-memory ring on the server, a 10 minute safety cutoff on the bridge, automatic expiry 25 seconds after the page stops polling, Stop keeps the buffer only for the browser-built CSV export, Clear forgets it, and nothing ever touches a folder. The BACnet browser sub-page is scaffolded and awaits the tool's files for verbatim integration. New plumbing: capture endpoints under /api/capture, bridge control topic $beez/bridge-ctl/<name>, per-point COV state in the retained bridge status, and capture instrumentation on single reads, RPM chunks, writes, COV subscribes and notifications. |
| 2026.07.19-6 | Audit trail, alarm dashboard, default alarm flags, and a sensor plausibility engine. The Admin Audit page now searches and paginates server-side (action, actor, target, detail and IP, tenant-scoped exactly as before) and exports the matching rows as CSV, itself an audited action. The Alarms page became a dashboard: selecting a site evaluates basic rules, advanced rules and the bridge-polled device alarms immediately and fills severity cards (Critical, Warnings, Sensor faults, Device alarms, Info) that act as clickable filters over one combined table; advanced-rule creation moved into a popup, and the explanatory lecture is gone. Alarm-point flag management moved to a new Admin Alarms tab with per-point toggles, a filter, and bulk actions; candidates by name (alarm, alm, fault, fail, trip) are now flagged by default at EDE import and once by a version-gated v10 backfill on the first start after this update, and a deliberate unflag is never overridden by later restarts. New sensor plausibility checks run first among the basic rules and classify physically impossible readings as sensor faults instead of low or high alarms: 0 %RH is a dead humidity sensor, not dry air, and never doubles as a low-humidity alarm; plain % keeps 0 and 100 as the normal damper and valve endpoints and only faults outside that range; temperatures are judged, after unit conversion, against role-aware ranges matched on the point's code and name (chilled water -10 to 30 degC, condenser water 0 to 60, hot water 5 to 98, outdoor -45 to 55, supply air 0 to 60 so a 100 degC or 212 degF SAT is caught, room and return air 0 to 50, wide -45 to 150 hard bounds otherwise), with water roles matched before air roles so a CHW supply is never judged as supply air; CO2 outside 250 to 10000 ppm and non-finite values are faults too. Schema is now v10. |
| 2026.07.19-5 | The ReadPropertyMultiple caveat closed in software. The exact call format, request schema, response shape and exception model of the installed BAC0 2025.09 were verified two ways: by reading its source directly, and end to end over real BACnet/IP on loopback against a bacpypes3 device, with the raw shapes now documented facts instead of assumptions (a response is a dict keyed like analog-value,1 holding property and value pairs; a stale object inside a good batch comes back as an inline ErrorType value; a whole-batch rejection raises a named exception). That verification caught three real bugs before any site saw them: a plain no answer arrives wrapped in an exception whose type name reads like a rejection and would have been recorded as a permanent device refusal, now classified transient; a stale EDE object would have falsely demoted a perfectly capable controller (batch poisoning) or been published as data (inline error), now excluded after its own single read fails and re-admitted the moment it answers again, proven live on the wire; and a single read of a dead object RETURNS None rather than raising, which the previous sweep, including the pre-RPM one, would have published as the string None forever, now counted as a failed read. Segmentation aborts halve the chunk (floor 4) with the working size persisted per controller, so the first request after a restart is already the size that controller manages. And a new read-only field tool, tools/test_rpm.py, probes any named controller in seconds (one single read, one RPM chunk, raw response, parsed values, and exactly what the bridge would record), which is how the one remaining unknown, vendor quirks, gets closed on site. |
| 2026.07.19-4 | ReadPropertyMultiple polling with per-controller capability memory. The bridge now reads points in RPM batches (20 per request by default, --rpm-chunk to tune, 0 to disable), cutting a 300-point sweep from hundreds of sequential round trips to about 15 requests, and it remembers what each controller told it: a positive rejection of the service (unrecognized-service reject, segmentation abort) is written permanently to bacnet-capabilities.json, keyed by device instance, and from the next cycle and every restart after, that controller only receives the single reads it can manage. A controller whose batched reads keep failing while single reads prove it alive is demoted the same way after three straight cycles; a failure in our own client call disables batching for the run without blaming any controller. Objects missing from an otherwise-good batch response are fetched with single reads in the same cycle, so nothing is ever lost. The memory file lives in the installation root, outside the folders the updater mirrors, so it survives updates; deleting a controller's entry probes it again. The whole feature is covered by a 30-check test suite driven through a fake BACnet client, including the restart case; the one honest caveat is that the exact BAC0 readMultiple call and response shapes are unverified against live hardware, so the parser accepts every plausible shape and the worst case is, by construction, exactly the previous single-read behaviour. |
| 2026.07.19-3 | Update delivery verified end to end, after the in-browser update button was reported failing for every package since 2026.07.18-3. The investigation reproduced the running platform's exact upload validation against the shipped package (accepted), confirmed from the session records that the build recipe is character for character identical to the last package that installed successfully, simulated the unattended updater's full file operations on a replica installation (data, tenants, config.json and uploaded graphics untouched; code replaced; mirror semantics honoured), and proved the schema 6 to 9 first-start migration on a faithful copy of a 2026.07.18-2 database (every point, display-unit override, user, stored API key, command and audit row preserved, twice, since a restart must be idempotent). Since the package side checks out completely, the remaining suspicion sits on the apply step of the machine itself, so this release adds the diagnosis tooling for exactly that: tools/verify_package.py checks any package with the same rules the server enforces before it is uploaded, and the guide now documents the manual update.bat fallback (the same engine, run interactively so the failing step is visible) and where every run writes its evidence (update-debug.log, tailed live on the Admin Update page). |
| 2026.07.19-2 | Schedule and Calendar objects (BACnet types 17/23): the EDE importer now captures these as metadata the moment a bundle is imported, instead of silently skipping them; the bridge scans the real content every 30 minutes, reusing the same read pattern already proven on ordinary points; writing a schedule follows the identical Stage 2 discipline as a point (automation_mode defaulting to manual, propose then approve, checked again at approval time), with the actual BACnet write call flagged as the least-proven piece of this release since there is no verified reference for the exact format a structured property like a weekly schedule needs. Device-native alarms: the bridge polls each device's own GetEventInformation summary every 2 minutes (request-response, not passive bus listening, a deliberately simpler and more provable capability to build without live hardware to test against), surfaced in the Alarms tab and to the AI tab. The AI tab grew from five tools to ten and from three chart types to six (scatter, carpet, Sankey added, reusing the exact same math already proven for the Trends tab), plus a schedule search and proposal tool. Found and fixed two real bugs while building this: the pending-actions list was silently dropping every schedule-write proposal (an inner join on points where a schedule proposal has no point_id at all) and had never actually been showing a point's own code correctly (a missing column alias), both now corrected with a properly tested left join. |
| 2026.07.19-1 | Aligned with beez-fm's beez-Coach AI Control Vision: the platform now follows the same staged automation model (Stage 2, assisted; "AI outside, safety inside" is non-negotiable, the AI never writes directly). Every writable point gained an automation_mode, defaulting to manual; only a point an admin explicitly puts into Approved automation accepts an AI-proposed action at all, and even then the AI only ever creates a pending proposal (value, reasoning, confidence, the point's value at that moment) that a person with operator access or higher must explicitly approve before anything is written, through the exact same write path and audit trail the Points tab's Adjust button already uses. The AI tab gained a Pending actions list and a propose_action tool, gated by automation_mode at the moment of proposal AND again at the moment of approval. Fixed a real layout bug found while doing this work: the Alarms toolbar had been sharing the Trends toolbar's sticky CSS class, which silently stacked its buttons vertically instead of in the intended single row. Added a phone-width layout for the AI tab, the Alarms tab, and the shared navigation and cards. |
| 2026.07.18-3 | A new Alarms tab: on-demand (button-triggered, deliberately not continuous, to keep rule evaluation off the same process that handles MQTT ingestion) basic rules that self-discover across a project (existing device alarms, high/low humidity, high CO2, sensor-far-from-setpoint using the same one-of-each pairing discipline as calculated power points, stale/no-data points, low battery) plus user-created advanced rules (threshold, deviation, command/status mismatch) with full CRUD, a category/severity dashboard, and a name-based alarm-candidate finder; the AI tab gained get_active_alarms and list_alarm_rules tools so it can answer questions about what is active, by category, and what to treat first. Schema groundwork also laid for BACnet Schedule/Calendar scanning (a bacnet_schedules table); the schedule management page itself, including live device scanning and the weekly calendar view, is a distinct, comparably-sized piece of work and is the clear next step rather than being rushed into this same release. |
| 2026.07.18-2 | Print report now uses landscape pages and gives every graphic its own full page instead of a fixed small box, skipping any analysis card never actually used; a new AI tab turns a plain-language question about a project into a short dashboard, Claude reasoning with three tools that wrap real platform analysis functions (aggregate, time in ranges, state totals) directly and choosing what to show, rendered by the browser through the exact same endpoints the Trends tab uses; a new Admin, AI subpage for the Anthropic API key the AI tab runs on, write-only from the API's own perspective, never returned by any endpoint or written to any audit entry once saved, not even masked. |
| 2026.07.18-1 | A Sankey flow chart (up to 16 selected points as nodes, a parent picker to build the tree, link width proportional to consumption or average), a custom SVG renderer since Sankey is not a core Chart.js chart type; the Print report button is now a distinct glossy blue rather than blending into the toolbar; the Trends toolbar is now two lines (period, quick-selects, Load, and Compare on the first; Print report, Chart type, Aggregate, and Export CSV on the second) and stays pinned to the top of the page as the long column of analysis cards below it scrolls. |
| 2026.07.17-4 | Scatter plot no longer requires an aggregation bucket: on raw, each reading pairs with whichever reading of the other point is genuinely closest in time (within a configurable gap), a standard nearest-neighbour join over both histories; a Print report button turns whatever is currently built in the Trends tab into a clean, printable page (or PDF via the browser's own print dialog), no server involved; a carpet plot (day-by-hour heatmap, colour by raw value or by deviation from a given reference) for reading a long period at a glance, built entirely on the existing hourly aggregate. |
| 2026.07.17-3 | Fixed a real bug behind a reported "recreate calculated points found none" case: once a point's unit was established (an EDE/bundle import setting it to the specific %RH, for example), a later live MQTT message carrying its own, less specific metadata (a bare % from a LoRaWAN gateway, for example) could silently overwrite it, breaking the exact-match rule dew point/wet bulb/humidity ratio pairing depends on. A point's unit is now sticky once set; live metadata still establishes it the first time, but never drifts it afterward. Re-importing a bundle remains the explicit way to correct a unit. |
| 2026.07.17-2 | Recreate calculated points for a single project from Admin, Projects (found and fixed a real bug in the process: a second refresh on the same equipment could see its own dew point and wet bulb outputs as extra Temperature candidates and silently stop pairing, which would have hit every installation on its second restart); update rollback (every update saves the code it replaces, and rollback.ps1 restores the most recent snapshot or a chosen one, itself undoable the same way); several points sharing a unit now draw as one grouped bar chart instead of separate charts; the range-distribution bucketed view is now one stacked bar per bucket instead of a chart per range; Distance from setpoint can exclude nights and weekends by working day and hour; a scatter plot for checking operating patterns, with an optional regression line. |
| 2026.07.17-1 | Fixed the points list project filter (the change listener was missing, so picking a project silently did nothing); the whole analysis workspace below the Trends chart now shares one From/To period, one Aggregate bucket, and one point selection with the chart above it instead of separate controls per card; State breakdown gained an optional Step for auto-binning a continuous reading and now always draws a bar chart; every bar chart uses the same colour as the point's own series above; the tree sidebar now runs the full height of the page, sticky, next to charts that match the main chart's width exactly. |
| 2026.07.16-4 | Three new visualizations built on one shared primitive (time spent in several ranges at once): a pie chart of totals across a period, a bar chart per range broken down using the same aggregation bucket as the chart above it, and an auto-generated, setpoint-centred distribution histogram (target plus a step, bands sized to the data automatically), the SAUTER SVC histogram idea with two numbers to set up instead of a full chart configuration. |
| 2026.07.16-3 | Time in state and time in range now use whichever point or points are already selected in the Trends tree for the chart, instead of a separate picker; select more than one and each gets its own breakdown. |
| 2026.07.16-2 | Fixed a regression between display units and the energy features: a cumulative meter or power point with its display unit overridden (for example a kWh meter shown in BTU) could stop being recognised as energy-capable, since the classification was reading the resolved display unit instead of the point's true physical unit. The auto energy metric selector and the energy pie chart are unaffected by any display-unit override now, exactly as before it existed. |
| 2026.07.16-1 | Calculated dew point, wet bulb, and humidity ratio for any equipment with one Temperature and one %RH point, computed live like calculated power; display units for any convertible value (temperature, volume, flow, pressure, energy, power) with a project default, equipment pattern rule, and per-point override, none of which touch stored data or calculations. |
| 2026.07.15-3 | Documentation refresh across the guide (first login, the CLI reference, the five Admin subpages, MQTT topic mapping, the audit trail, another superadmin, and bundle re-import all documented), plus this changelog page. |
| 2026.07.15-2 | Re-importing a project's bundle now refreshes an existing point's name, kind, unit, and writable flag from the new file instead of only adding points that are brand new. |
| 2026.07.15-1 | Added time in state and time in range analysis (fan run time, door open time, a setpoint's time above or below a threshold); confirmed and documented that calculated power points are always computed live by the platform, never shipped inside a project bundle. |
| 2026.07.14-4 | Fixed graphics mimics rendering as solid black squares (the security policy was blocking the SVGs' own styling); fixed BACnet units still showing as raw numbers for bundle-imported projects (a second, separate unit table had been missed). |
| 2026.07.14-3 | Added an in-browser updater (Admin, Update: upload a package zip and apply it without touching Windows directly); new users now default to requiring a password change at their first login. |
| 2026.07.14-2 | Fixed BACnet units showing as raw numbers (96 instead of ppm); binary points show their own on and off text instead of 1 and 0; energy meters auto-select the correct consumption metric; charts draw one Y-axis per unit automatically; MQTT topics can be connected to a project explicitly; Admin split into Projects, BACnet, MQTT, Users, and Audit subpages; added a full audit trail; a superadmin can create another superadmin; fresh installs start with a default admin account instead of a setup command; added per-user security rules. |
| 2026.07.14-1 | Closed all ten findings from a full security assessment: escaped every rendered value plus a strict content security policy against stored XSS, login brute force throttling and a timing side channel fix, removed the session token from URLs in favour of short-lived single-use tickets, real session revocation with a shorter default session length, pinned dependencies, a hardened MQTT broker, and a raised password hashing work factor with transparent upgrade on login. |
| 2026.07.13-6 | Replaced the batch file updater with update.ps1 (PowerShell), which cannot close silently on failure, logs a full transcript, and can be dropped into an existing installation to self-heal a previously broken updater. |
| 2026.07.13-5 | Fixed the bridge local-IP dropdown never actually populating on the create-bridge path; the COV-to-polling fallback now verifies real subscription acceptance, telling a server that refuses COV apart from a link that is simply down. |
| 2026.07.13-4 | Added the Sites tab (per-site communication status, pause and resume, a communication log); one dead site can no longer take the whole bridge down; COV became the default bridge transport with automatic polling fallback; the bridge local IP became a detected-interface dropdown; fixed a project-to-bridge disconnect not applying until a manual restart; full forensic logging added to the updater. |
| 2026.07.13-3 | Hardened the console against a VPN or proxy answering instead of the platform, with a clear message instead of a JSON parse error, plus a troubleshooting guide entry for VPN interface bounces. |
| 2026.07.13-2 | Added deletion for users, projects, and tenants, typed-slug confirmation throughout, complete cleanup (retained MQTT topics cleared, bridges cycled, folders removed). |
| 2026.07.13-1 | Introduced version identity: VERSION.txt, the /api/version endpoint, the console header badge, and an unmistakable success banner in the updater. |
Before version tracking began (2026.07.11 to 2026.07.13), the platform
was built from scratch: the FastAPI, MQTT, and SQLite architecture, the BACnet bridge,
the first pilot building onboarded from an EDE export, the tenant and project data
model, calculated power points, the aggregation and energy engine, and the original
analytics workbench.