OpenConstructionERP v14.4.0
An upgrade that crosses this year's releases applies again. One revision named a foreign key at sixty five characters, PostgreSQL cuts identifiers at sixty three, and the toolkit refuses to send a name it knows will be cut, so the revision failed before a single statement reached the database. PostgreSQL runs schema changes inside a transaction, so that one name rolled back the whole upgrade rather than stopping at the revision that caused it, so a site coming from 14.0.0 received none of the eighteen revisions waiting for it. The contracts feature could not be installed on PostgreSQL at all. The name is now scoped to its own table without repeating the table it points at, which is how the primary key and the unique constraint either side of it were already spelled. Every test we had passed it, because they run on SQLite where no such limit exists, so the fix ships with a scan that walks every revision and fails on any identifier over sixty three characters. It reads the revisions as text rather than asking a database, which keeps it cheap enough to run anywhere. One offender existed in the entire history and it is this one. It was found on a production upgrade rather than by us.
An assembly applied from a template no longer adds two currencies together and calls the result one number. The preview converts each component into the target currency and totals them, and when it could not find a rate for one of them it added the unconverted amount into that same total anyway, so a hundred euro plus a thousand of a currency nothing could price was presented as eleven hundred euro with a warning beside it. A warning does not undo a number somebody is about to quote from. Rates now resolve through one place for both the preview and the transfer into a bill of quantities, which previously consulted only the project's hand typed rate column. That column is empty on a default project, and when it held nothing both paths kept the foreign figure and carried on. The order is the project's own rate first, because it is usually fixed by contract and a contract does not stop applying because a market moved, then the platform rate register, then nothing. Nothing is a real outcome here: the total refuses to present a converted figure rather than passing an unconverted one off as converted. The project column is read only when the target is the project's own base currency, because its convention is base units per one unit of foreign, and using it in the other direction would invert the rate in silence and return a figure that looks entirely reasonable.
The assistant can reach live project data whatever provider is behind it. Tool access was decided by a list of two provider names written into the code, so a chat backed by anything else fell through to a prompt that instructs the model to say live project data is unavailable and to suggest switching provider. Twenty providers are offered in the picker and two were admitted, so the answer was true about the implementation and wrong about the provider, including one the settings page itself recommends. OpenRouter now receives a tool schema over the protocol it already speaks, and drops to plain text only when the provider refuses the schema rather than because of what it is called. A reply carrying an empty list of choices also stopped the request outright, since the fallback in that line applied only when the field was missing and not when it was present and empty, and the three places that read a reply were each written against the two response shapes we knew about and answered anything else with an empty result, which is why an unfamiliar reply arrived as a blank bubble rather than as an error. They understand the shape they are given now. The plain text request is unchanged to the byte and the older provider path is untouched, so no key can reach an endpoint it was not issued for. The example environment files were missing five provider key names the application reads, which matters for headless and container installations where nobody opens the settings page.
Opening a drawing in DWG Take Off frames the drawing rather than its labels. The box that decides the opening view was built from every entity with text among them, and it estimated each label at the height it was authored at, so one annotation sized for a paper sheet inside model space could push the frame out far enough that the geometry it belonged to arrived as a smudge in a corner. That is worst at fit to window, which is the first thing anyone sees on opening a file. The box is now built from geometry alone. Text is measured only when a drawing carries no geometry at all, which is the single case where framing nothing would be worse than framing a label. Annotation is still drawn to a readable band rather than to true scale, and that is a deliberate choice about what this viewer is for rather than a leftover: somebody here is measuring geometry and reading labels, so a label too small to read carries no information and one that fills the canvas hides the thing being measured. The two used to be coupled, the frame asking how large the text was while the text asked how large the frame was, and bounding either end on its own left the pair disagreeing, which is how a correctly framed drawing could still end up underneath a single enormous glyph. With text out of the frame that loop is open and the size of a label answers to legibility and nothing else.
Block definitions were filed as drawing sheets, which is why the sheet picker offered entries that are not sheets and why opening a file could land on a door rather than a plan. A definition now carries a block tag and no sheet of its own, so the filter that was already there drops it, and the geometry it had been carrying becomes usable instead of dead weight. Each referenced definition ships once and the viewer places it per insert. Joining them on the server instead would have multiplied the geometry by the number of placements, so five hundred placements of a forty entity block would have cost twenty thousand records rather than forty.
Rotation reaches the viewer in one unit. The viewer hands the value straight to the canvas, which takes radians, and the path that reads a DXF was emitting degrees, so a right angle rendered as ninety radians. The path that reads a converted DWG was wrong in the other direction and quieter about it. The export writes an angle either as a bare number already in radians or with a letter d meaning degrees, which is why the code that reads the start and end angles of an arc goes through a helper that understands both. Rotation went through the plain number reader instead, and a value carrying that letter is not a number, so it came back as nothing and nothing became zero. Text authored on an angle came back upright and every placed block faced the same way, with no error anywhere, which is the harder of the two to notice because an unrotated drawing looks like a drawing. Both paths read rotation the way the arc angles beside them are read. The only test on this had asserted a rotation of zero against a fixture that left the cell empty, and zero is zero in either unit, so it could not have told the two apart.
The rule that checks a contract has its parties asks the same question the signature block asks. The rule and the signing bridge each carried their own idea of who executes a contract and the two had drifted apart. The rule wanted a named employer and a named contractor, which is one contract's shape. A subcontract is executed by the main contractor and the firm selling the work, with no employer on it at all, so the rule refused every subcontract in the product. It now asks for two distinct signing roles and takes the list of those roles from the signing bridge rather than restating it. While the two lists were separate, the rule passing and the signature block having somebody to name were two conditions that happened to agree, and nothing would have reported the day they stopped agreeing. A party entered as a link to a contact, a subcontractor or a user carries no name of its own, and those names are now resolved before the rules read the contract, so a row that looks empty in the table is no longer read as an absent party. A nameless row is reported as nameless, which is a different repair from adding a party that is already there. The rule also answers on a draft, which is the status a contract is in for the whole time somebody is getting it ready to sign.
The check that confirms a converter installed looks at the install rather than at four bytes of it. It read the first four bytes of a single file out of the two hundred and fifty one it downloads and never looked at the machine field, so the one thing the converter was accused of being, a binary built for the wrong word size, would have passed it. It reads the architecture now and confirms that the files which are supposed to be there are there. The mirror it downloads from only ever added, so a real install still held a binary that upstream had deleted three days before that install last ran, sitting in the folder our own error message tells people to fix by downloading again. The advice we ship could not work, and the person following it would have concluded the product was broken rather than the message. Reinstalling was also doing nothing to the folder it was about, because the health check called a partial folder failed while the install endpoint called it already installed and downloaded nothing.
The Cases hub filters hold more than one choice at a time. Picking a second company type, role or category replaced the first, so an estimator who also runs procurement had to choose which half of their job to look at. All three now accumulate, options are combined inside a facet and required across facets, and the summary line has become a row of chips that each drop one filter. A filter set before this change is carried over rather than cleared. The dashboard card follows the same change and ranks a playbook by how many of the picked roles and company types it matches rather than by whether it matches at all.
The worked example inside the bill of quantities paste box shows its columns again. Every tab and newline in it had lost its backslash, in English as well as in twelve translations, so a string whose whole purpose is to demonstrate a tab separated layout arrived as one unbroken run of letters and digits and demonstrated nothing. The check we had for this compares each translation against the English value and requires the English one to carry the escape, so when English is the damaged file the key drops out of the scan rather than failing it. Twenty four further values across nine languages had lost the same backslashes, and five of those turned out to be half English underneath once the sentence boundaries came back, which nothing reports because the key exists, is quoted correctly and differs from English. Two scans run on every push now, one for a value that dropped an escape the English value still carries, one for a word that mixes Cyrillic and Latin letters, which is how a homoglyph enters a translation and stays there.
Mongolian stopped reporting failures as successes. Three messages, among them the one shown when a bill of quantities import fails, used the past tense of the verb to succeed where they needed its negation, so a failed import announced that it had worked. The Failed tile on the dashboard carried a phrase that is neither word. The Reject control on document approvals was labelled with the word for download, which shares a stem with the word for refuse. Delete and Remove were each labelled with the other one's word, in both directions at once. The Clear button carried the weather sense of the word, a cloudless sky, though the two weather keys that legitimately use it are left as they were. One label was in Russian. Every replacement is a word the same file already uses for the same thing, counted before it was used, so no language gained a word from us. Two longer passages are damaged end to end rather than in one word and are left for a native pass instead of being half repaired.
A sheet marked superseded no longer reads as the current one. The pair of badges on the sheets register had drifted in meaning across twelve languages, in one case into its exact opposite: Mongolian labelled a superseded sheet as the latest update, while the badge for the current sheet carried a word meaning betrayal. Elsewhere they were wrong rather than inverted, a discarded sheet in Korean, a deleted one in Hindi, a checked one in Finnish. English spells superseded at four keys and beside them at a fifth, and the first pass repaired only the badge it had been shown, so the filter, the count and the chart axis kept the wrong word for another day. All five now agree inside each language, and where they disagreed about gender the pair's own subject decided it. Two of the disagreements were introduced by that first pass, which rewrote a badge to a word its four siblings did not use; both are back to what the file already said. No check can see any of this. The keys are present, the quoting is correct, and each value differs from English, which is the whole of what a translation check asks.
The desktop crate is back in step with the rest of the release. The check that keeps the version literals aligned read six of them and the crate manifest was not among them, so the manifest and its lockfile had drifted eleven minor releases behind everything else without anything noticing. Both are read now, anchored on the package entry rather than on the first version line in the file, which is what a lockfile carrying five hundred and thirty eight of them demands.
The sheet register has a screen worth opening. It listed sheets and stopped there, so telling a complete drawing package from a partial one meant exporting it and counting by hand. A single sheet now opens into a detail drawer, the set carries a small insights band above it, and the index sits in its own sidebar group beside the other document surfaces rather than under a heading that gave no hint it was in there. The deep link from takeoff into a sheet followed the old location and now follows the new one.
Uploading a revised drawing set retires the sheets it replaces instead of standing beside them as an equal claim. Every sheet a split produced was marked as the current revision and none was ever marked as superseded, so a second upload left two of each sheet number, both claiming to be current, told apart only by which file they came from. The completeness check reconciles a project against its drawing index by sheet number and reads only the current sheets, so it received a doubled set. It did not report those as duplicates, because it matches on the number and two rows carrying one number collapse into one. What it did instead was compare the index against whichever of the two revisions came last in the list, and nothing decided which that was, so the same check could answer differently on two runs and could answer for a sheet that had already been replaced. A number already current in the project is now superseded by the incoming one, which points back at the sheet it replaces. Nothing is deleted and no value is overwritten. A page whose title block cannot be read has no number to match on and is left alone rather than guessed at, and when one file carries the same number on two pages the register shows both, because that is what the file says and it is not ours to resolve. The register itself still lists every revision it has ever held, since an index that hides the drawing somebody is standing over is worse than one that shows it, and a superseded row now says the word. It had been printing the same dash that the revision date and scale columns beside it print when they are empty, so the one state the column exists to report looked exactly like missing data, and the word that distinguished them reached only the people reading it through a screen reader.
The register lists its sheets in an order the database has to repeat. It sorted on the page number alone, which is not unique, because a set uploaded again starts at page one again and one import can register two revisions in the same instant. What decided those ties was an index that a database built by the application receives and a database built by migrations never does, so the same query answered differently depending on how the site had been installed, and the order looked settled on the machine anyone would have checked it on. The route pages with an offset and a limit, and two pages that resolve one tie differently show a row twice and skip the one it displaced. The sort now runs page number, then the instant the row was created, then the id, which is unique and is what makes the order total. This is the same fault the completeness check was reading through, described above from the other end. The lookup that decides which sheet an incoming revision records as the one it replaces had it too, and there it mattered more, because a register that already holds a number twice is holding it because one upload wrote that number on two pages, which is exactly the case where two rows carry one instant and the sort has nothing left to order them by. Both now break the tie the same way, so they cannot disagree about which of two rows is the later one.
A page of that list now says how many sheets matched. The limit caps at five hundred and the register asks for exactly five hundred, so a project at the cap returned a truncated page that was indistinguishable from a whole one. The count travels in a header rather than changing the body, which stays a plain array as every other list in the module does. The setting that tells a browser which response headers a page may read did not name it, and a browser hides every header a server does not name there, so the count would have arrived at a frontend served from another origin and been unreadable. Two other modules have been sending that same header into that same silence for as long as they have had it.
The register reads in the word each language uses for a drawing revision. Eight locales carried a term that parses and says the wrong thing, which no check we run can see, because every one of them asks whether a string is present and translated rather than whether it is the right word. Thai described a revision across eleven keys with the word for reconsidering something rather than the letter stamped in a title block. Finnish named the discipline column with the word for obedience and order. Mongolian used one word for both a page of a PDF and a drawing sheet, so the step explaining the split read that a page becomes a page, and the button offered to cut a drawing set into pages. Norwegian and Swedish made the same conflation pointing the other way and called a document's index page an index sheet, which is a different thing and the field is asking about the page. Danish, German and Swedish left the English name of a module sitting inside an otherwise translated sentence. Dutch had a heading that was neither Dutch nor English and a word misspelled twice over. Hindi was checked and left as it is: it renders discipline one way in a hundred and three places and another way in sixty four, so the three keys here are not wrong on their own, they are three instances of a split that runs through the whole file and wants its own pass.
A field read off a title block no longer carries its neighbours. Scale was captured with a pattern that treated a line break as ordinary spacing, so a sheet reading SCALE 1:50 with a revision letter on the line below was stored as the scale plus the first word of that next line, and the stored text is what the sheet detail shows. Title blocks also lay their fields out in columns that arrive as a single line of text, so a value captured to the end of that line took the two fields beside it as well. A value now ends where the next field begins. Two more cases were wrong in the same place and covered by nothing: an imperial scale was cut at its equals sign and stored as half of itself, and a sheet title sharing a row with the drawn by box was stored carrying that box. Scales written as words, NTS and VARIES and AS NOTED among them, read as written.
Four dashboard panels answer for the project you picked. The pass before this one scoped the rollup that most of the page is built from, and these four were not reached by it. The cross module activity feed accepts a project filter and was given none, so it listed every project's events beside widgets answering for one. The tile offering to continue your most recent estimate read the unscoped rollup and could print another project's name directly under the project just selected. The project overview panel keyed its cache on the number of projects, which looks like scope and is a lossy stand in for it, so two different sets of projects of the same size shared a single cached answer.
The fourth is the latest site photos card, and it was wrong in a way the other three were not. The card is project facing: clicking a photo switches you into that project and opens its gallery. It asked for photos across every project you can see, with no project in the request and none in its cache key, so it showed one project's site documentation under the name of another. Both halves had to change. The request decides what the server sends back; the cache key decides whether anything is requested at all when the selection changes, and a key that does not carry the project serves the previous one's photos with no request made, which on screen cannot be told apart from a server ignoring the filter. On the server the filter narrows within what you are allowed to see rather than replacing it, so it can only ever return fewer photos, never different ones.
Project overview stays workspace wide on purpose, as do the onboarding checklist and the system status card, because they exist to compare projects against each other and have nothing to say about one. What changes is that nothing on the page claims to describe the project you picked while quietly reporting the whole estate.
Every row in the contract completeness list gets a key of its own. The key was the rule id joined to the element reference, falling back to the row's position only when the reference was missing. That reads as guarded and is not, because a rule that files one finding per check stamps every one of them with the same rule id and the same reference, which is the contract's own id and never absent. So the fallback could not fire, two findings arrived under one key, and the browser resolves that by dropping one of them from the screen. The key is now built in one place that both this list and the compliance gate import, rather than copied into each of them, and a duplicate is told apart by its position inside the duplicate group so that adding an unrelated finding above it does not move it. The reference itself is unchanged: making it unique on the server would hide this rather than fix it, and would stop the reference being the id of the thing the finding is about.
The counter on the Cases hub is named for what it counts. It was never a count of the project's cases. It is your own pin list for that project, held in your browser, and it reads zero on a fresh project for everybody until something is pinned, so the label made a claim about the project that the number under it could not support. The previous release added a sentence explaining the zero, which explained the number without withdrawing the claim. The label is now the thing the number has always been counting, under a new key rather than as new wording under the old one, because 29 locales carried the previous sentence and changing only the English would have left the other 28 faithfully translating a claim we had just removed.
Opening the CAD and BIM tab lists what is installed instead of starting all of it. A converter problem used to meet people on arrival rather than at the moment it would have mattered, and there is a Check now button for when the verdict is actually wanted. Not checking makes the healthy count zero on a perfectly working machine, and the wording was written for the checked case, so an unchecked state is now explicit rather than borrowing the one that means install something. It keeps the collapsed badge, which had been gated on everything being healthy and would otherwise have reopened the full panel for anyone who had collapsed it. A converter that is genuinely broken still reports itself the moment anyone checks, and a real conversion still pre-flights on the server, so nothing is hidden from the person who converts something.
Frontend tests now block a merge. The lane that gates a merge was a by-name list of Python test files, so every frontend test in the repository was enforced by nothing, including the ones written for the faults in this release. There is a frontend job in that lane now, the Python list grows by the tests for the identifier length scan, the contract party rule, assemblies currency blending and the version literal reader, and a step ahead of the run confirms every named file still exists, so a rename becomes a red lane rather than a filter that quietly matches nothing.
Full Changelog: v14.3.0...v14.4.0