<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: informat</title>
    <description>The latest articles on DEV Community by informat (@informat).</description>
    <link>https://dev.to/informat</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3901489%2Fab8d13c5-0932-420d-9f96-849a0743c7cd.png</url>
      <title>DEV Community: informat</title>
      <link>https://dev.to/informat</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/informat"/>
    <language>en</language>
    <item>
      <title>You Do Not Have a Test Environment. You Have a Test Record.</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Wed, 21 Oct 2026 01:41:14 +0000</pubDate>
      <link>https://dev.to/informat/you-do-not-have-a-test-environment-you-have-a-test-record-ipl</link>
      <guid>https://dev.to/informat/you-do-not-have-a-test-environment-you-have-a-test-record-ipl</guid>
      <description>&lt;p&gt;At the end of last November, the quality manager of a medical-device manufacturer — I'll call her Renata — sent us a screenshot instead of a ticket. It showed her company's batch-release table, filtered to the current month. Eleven of the rows were hers. She had built them three weeks before go-live while learning the approval flow, and had typed the word TEST into the batch number field so that she would know which ones to clean up afterwards.&lt;/p&gt;

&lt;p&gt;The go-live went fine. The audit did not. Her auditor wanted to know why eleven released batches had no corresponding production order, and "those are mine, I was practising" is not a sentence you can say to an auditor, because by then the platform had stamped each row with her name, a timestamp, and a completed approval chain.&lt;/p&gt;

&lt;p&gt;Eleven rows. No code involved, no deployment, no migration. That is the part I keep coming back to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copying code is a solved problem. Copying a business is not.
&lt;/h2&gt;

&lt;p&gt;Every other kind of software has a well-worn answer here: you branch, you spin up a staging environment, you point it at a copy of the database with the names scrambled. The reason it works is that the application and its state are separable. You can ship the code without the customers.&lt;/p&gt;

&lt;p&gt;In a low-code platform that separation quietly disappears. What a customer builds is not a program; it is a configuration plus reference data plus files plus credentials plus the history those three have already accumulated. The "application" is a thin interpreter over the business's own tables. So a test environment cannot be a copy of the code, because the code is empty. It has to be a copy of the business, and nobody wants a copy of their business sitting on a server for three weeks going stale.&lt;/p&gt;

&lt;p&gt;What teams do instead is copy the definition and leave the data behind. That environment loads, the forms render, and the first automation fails because the customer master it joins to has four rows instead of forty thousand. A test environment that cannot reproduce the data cannot reproduce the bug, so people go back to the only place where the data is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our transaction ends when the automation ends
&lt;/h2&gt;

&lt;p&gt;We are proud of our atomicity story, and it is true as far as it goes: one automation runs in one transaction, and if it throws or is told to stop, everything it wrote to its own tables rolls back.&lt;/p&gt;

&lt;p&gt;That guarantee is scoped to a unit of execution, not to a unit of intent. A test is a session: a person, a form, a submit, a notification, a second person approving, a dashboard refreshing. Only the first fragment is inside a transaction. By the time Renata had clicked through four screens, three of those clicks had already committed, and each was a decision made by a different part of the platform that had no idea it was participating in a rehearsal.&lt;/p&gt;

&lt;p&gt;The rollback we offer is a rollback of a step. The rollback a tester needs is a rollback of an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The leak is not the data. It is the message.
&lt;/h2&gt;

&lt;p&gt;Then there is the detail that turns a training mistake into an incident. The approval flow had a notification step. When Renata submitted her eleventh test batch, a message went out through the company's messaging client to the plant director, the QA lead, and — because the escalation rule was still at its default — the general manager.&lt;/p&gt;

&lt;p&gt;Nobody could recall that message. There is no draft state for a notification that has already been read on someone's phone at 9:40 on a Tuesday night.&lt;/p&gt;

&lt;p&gt;We do have a suppression mechanism: a thread-level flag that a script can set to stop notifications during a batch run. It is a real feature, built for imports and bulk updates. But it lives in the script runtime, so the person who can use it is the person who writes JavaScript. The person building the approval flow in the designer — the one this product exists for — cannot reach it. The "test mode" exists, and it is available to exactly the users who need it least.&lt;/p&gt;

&lt;h2&gt;
  
  
  A test record has no colour
&lt;/h2&gt;

&lt;p&gt;Every system I worked on before this one had some way of marking synthetic data: a flag column, a tenant prefix, a partitioned schema, at minimum a naming convention the reporting layer knew to ignore.&lt;/p&gt;

&lt;p&gt;Ours has none. A test record is a record. It counts toward the row quota. It appears in the dashboard that the CFO opens on Monday. It is included in the export that goes to the auditor. It shows up in the change log with a real user's name, which is worse than showing up as anonymous, because a log with a name on it reads like testimony.&lt;/p&gt;

&lt;p&gt;Renata's team did the reasonable thing and wrote a cleanup script. It ran at the end of the pilot and deleted the rows, and the deletion itself became an event: eleven deletions by a quality manager in a table that is supposed to be append-only, each one recorded. The audit trail of the cleanup was longer than the cleanup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scheduler does not have a tomorrow
&lt;/h2&gt;

&lt;p&gt;The hardest case is the one nobody demonstrates. A nightly job that closes overdue work orders, an escalation rule that fires after four hours, a monthly statement. How do you test something whose trigger is the passage of time?&lt;/p&gt;

&lt;p&gt;You can wait. You can also invoke the same automation over HTTP by hand, which is what competent teams do — and which tests the logic while skipping the trigger, so the schedule itself stays untested until its first real night.&lt;/p&gt;

&lt;p&gt;What you cannot do is ask the platform what this job will do at 2 a.m. on the last day of the quarter, against data that will exist then. There is no dry run, no as-of date, no shadow execution. And because only one scheduled job can execute at a time, a manual test run at the wrong minute can consume the slot the production run was waiting for — the test does not merely fail to prove anything, it occasionally displaces the real work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a real sandbox would cost
&lt;/h2&gt;

&lt;p&gt;I have costed it. The number is not the reason we have not built it. The reason is that a sandbox forces a decision nobody wants to make in front of a customer: which side effects are real.&lt;/p&gt;

&lt;p&gt;Database writes can be isolated; that is a copy of a database. Notifications, messaging, email: do we swallow them, route them to a test inbox, or let them out with a banner? Every option is visible to end users, and every option is wrong for somebody. Credentials: the sandbox needs the ERP's test endpoint, and half our customers do not have one, so it either reaches production systems or cannot run the integration at all.&lt;/p&gt;

&lt;p&gt;Then the part that actually kills it. A sandbox needs data, and a copy of production data in a lower environment is a security incident waiting to be scheduled. Subsetting and masking sound like a weekend of work and are an ongoing programme, owned by someone, reviewed annually, with its own audit findings.&lt;/p&gt;

&lt;p&gt;A sandbox is a second product, priced like an environment and staffed like a compliance function.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;Our documentation says the platform supports automated testing and continuous integration. I wrote parts of that page. At the time I meant it: the sentence is about our own regression suite, and the customer reads it as a promise about theirs.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is not that we lack a sandbox. Most low-code platforms do, for the same economic reasons, and their customers have adapted with prefixes and test accounts and prayer. The uncomfortable part is that the absence is invisible in the demo. A demo never needs a second environment, because a demo has no history to protect. The cost arrives later, in an audit, in a message that cannot be recalled, in eleven rows that are true statements about a false event.&lt;/p&gt;

&lt;p&gt;We sold the removal of the deployment step as a feature. We did not mention that the deployment step was also the thing that separated trying from doing.&lt;/p&gt;

&lt;p&gt;Renata's eleven rows are still there. They are correct now, in a way — annotated, explained, accepted by the auditor with a finding attached.&lt;/p&gt;

&lt;p&gt;The platform did exactly what she told it to. It recorded a batch release, because she asked for one, and it had no idea she was only asking.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>testing</category>
      <category>architecture</category>
      <category>enterprise</category>
    </item>
    <item>
      <title>A Timeout Is Not a Failure. Your Platform Thinks It Is.</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Tue, 20 Oct 2026 02:34:29 +0000</pubDate>
      <link>https://dev.to/informat/a-timeout-is-not-a-failure-your-platform-thinks-it-is-d64</link>
      <guid>https://dev.to/informat/a-timeout-is-not-a-failure-your-platform-thinks-it-is-d64</guid>
      <description>&lt;p&gt;At the end of March, an accounts payable clerk at a supplier called Wei, the finance director of a 500-person equipment manufacturer, to ask a small question. Why had she received two purchase orders for the same forty-foot container — 4471 and 4472 — same date, same amount, same twelve line items?&lt;/p&gt;

&lt;p&gt;Wei pulled both records. Both were approved. Both had been created in our platform fifteen seconds apart, at 14:07:11 and 14:07:26. No human being had touched anything in between.&lt;/p&gt;

&lt;p&gt;The integration log explained it in one line. Their ERP had pushed the order to us and waited thirty seconds for an answer. We took longer than thirty seconds — the insert itself was fine, but an automation attached to it was not — so the ERP gave up and tried again. We had already committed the first order. The second call created a second one.&lt;/p&gt;

&lt;p&gt;Everyone involved did exactly what they had been told to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  A timeout is not a failure
&lt;/h2&gt;

&lt;p&gt;A timeout is a sentence about the caller, not about the work. It says: I stopped waiting. It does not say the request never arrived, and it does not say the other side did not commit. There are three states behind a timeout — never arrived, arrived and committed, arrived and still running — and two of the three mean the work exists.&lt;/p&gt;

&lt;p&gt;Our platform collapses all three into one word. Then it acts on the word: it treats the absence of an answer as an answer, and lets the caller's retry through as if it were a brand-new intention.&lt;/p&gt;

&lt;p&gt;That single conflation is where the duplicates come from. Not a race, not a bug in a queue. A system that cannot distinguish "I don't know" from "no".&lt;/p&gt;

&lt;h2&gt;
  
  
  Our transaction ends at our own wall
&lt;/h2&gt;

&lt;p&gt;The documentation is unusually honest about atomicity: one automation is one transaction, and if it throws or is told to stop, everything it did to the data tables rolls back.&lt;/p&gt;

&lt;p&gt;That guarantee is real, and it is narrower than it sounds. It covers our tables. It does not cover the ERP on the other end of an HTTP request, and it cannot, because we are not the one holding that lock.&lt;/p&gt;

&lt;p&gt;So the rollback is asymmetric. We undo our half; their half stays. A purchase order can be cancelled in our database and alive in theirs. And because rollback feels like safety, it also licenses the retry — we undo, we try again, and the second attempt lands on top of a first attempt that was never undone.&lt;/p&gt;

&lt;p&gt;There is a second asymmetry underneath. The script runtime is synchronous — no promises, no timers, no background workers — so a retry cannot be scheduled for later. It happens inside the same transaction, holding the same locks. A retry is not a courtesy; it is a second write inside the critical section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The identifier arrives after the act
&lt;/h2&gt;

&lt;p&gt;Look at what the write API asks for. An API key, a table identifier, and the row. That is all. The record's id is issued by the platform after it decides to accept the row.&lt;/p&gt;

&lt;p&gt;This ordering is the whole problem. Idempotency requires the caller to name the intent before the side effect, and the server to remember the name. Here the only name a write has is the one the write receives. At the moment of intent there is nothing to name. So when the client retries, it has no way to say "this is the same thing I asked for twenty seconds ago", and we have no way to agree.&lt;/p&gt;

&lt;p&gt;The response does carry a request identifier, useful for support tickets and useless for deduplication: it is minted per request, so the retry gets a new one. A receipt for a different transaction.&lt;/p&gt;

&lt;p&gt;The same hole exists on the human path: a double tap on Submit, a refresh on a slow page, a proxy replaying a POST. All produce a second row, because nothing in the write path claims sameness.&lt;/p&gt;

&lt;h2&gt;
  
  
  The customer's workaround is also a race
&lt;/h2&gt;

&lt;p&gt;Wei's team did what competent teams do. They wrote a script that runs before the insert: query the table for a row with the same supplier, the same amount, and the same delivery week; if one exists, skip. It worked for a while, and they were proud of it.&lt;/p&gt;

&lt;p&gt;It is a check followed by a write, with no lock in between. Two retries a few hundred milliseconds apart can both query, both find nothing, both insert — exactly what the original bug did. Except now the duplicate comes from our customer's code, so it becomes their ticket, not ours.&lt;/p&gt;

&lt;p&gt;The pattern repeats: the platform withholds the guarantee, the customer reimplements it badly, and the failure moves somewhere we no longer have to look.&lt;/p&gt;

&lt;h2&gt;
  
  
  A unique index is not idempotency
&lt;/h2&gt;

&lt;p&gt;The other usual answer is to put a unique index on the order number. It is the one place in the platform where a second write is physically refused, and it does catch 4472 in many cases.&lt;/p&gt;

&lt;p&gt;But refusal is not idempotency. Idempotency means the second attempt returns the first result — the same 200, the same body, the same id. A unique constraint returns an error. And an error on the second attempt is indistinguishable, from the client's side, from a genuine conflict: someone else legitimately used that number, or the row failed validation for an unrelated reason.&lt;/p&gt;

&lt;p&gt;The correct client behaviour on an error is to escalate. So a person re-keys the order by hand, slightly differently — a different supplier contact, a corrected delivery date — and now the supplier has three versions of one container and a phone call to make.&lt;/p&gt;

&lt;p&gt;We turned one duplicate into one duplicate plus one manual entry, and filed it under data quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the fix actually costs
&lt;/h2&gt;

&lt;p&gt;The mechanics are not exotic. A key table with a unique constraint on scope and key. Insert the key before the outbound call. If the insert collides, the caller is a retry: return the stored response, or "in progress", but never do the work twice. Store the response in the same transaction as the business row. On failure, mark the key failed with a reason, and decide explicitly whether the key may be reused.&lt;/p&gt;

&lt;p&gt;Every part of that is expensive in a way a demo never shows.&lt;/p&gt;

&lt;p&gt;The key has to come from the caller, and our API has no field for it. Adding one is small; getting every integration, mobile client, and middleware vendor to send a stable one is a year of conversations.&lt;/p&gt;

&lt;p&gt;The in-between state becomes visible. A key that is received but not done is a pending record, and pending needs a sweeper, a timeout, an owner, and a screen. The honest label for that screen is "we are not sure the other side got it", which is the sentence product teams avoid printing.&lt;/p&gt;

&lt;p&gt;And the key has to be derived from business meaning — supplier, period, revision — which means a human can derive it wrongly. A wrong key does not create a duplicate; it silently swallows a genuine second order. That failure is quieter than the one we set out to fix, which is why most teams stop halfway and call the half a feature.&lt;/p&gt;

&lt;p&gt;The bill also arrives late. Nothing alerts at 14:07:26. Both rows pass every rule and look like ordinary work. The duplicate surfaces three weeks later, in a payment run, when someone compares a bank file to a supplier statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;We did not ship a duplicate order. We shipped a word. "Failed" is what our integration layer says when it does not know, and everything downstream — the rollback, the retry, the green checkmark on the second attempt — behaves as if the word were true.&lt;/p&gt;

&lt;p&gt;A timeout is an absence of information. We treat it as information, and then act on it automatically, at the exact moment a human would have picked up the phone.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is not that we lack an idempotency key. Most platforms do. It is that we built an impressive transactional story — one automation, one transaction, all or nothing — and then drew the boundary at our own wall, where all-or-nothing quietly becomes our-half-or-nothing.&lt;/p&gt;

&lt;p&gt;Fifteen seconds. Two orders, both correct, both approved, one of them imaginary.&lt;/p&gt;

&lt;p&gt;We call the second one a duplicate. From the supplier's side, it is simply the second order we asked for.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>api</category>
      <category>architecture</category>
      <category>enterprise</category>
    </item>
    <item>
      <title>The Number on the Document Is a Promise Your Platform Cannot Keep</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Mon, 19 Oct 2026 02:07:35 +0000</pubDate>
      <link>https://dev.to/informat/the-number-on-the-document-is-a-promise-your-platform-cannot-keep-coh</link>
      <guid>https://dev.to/informat/the-number-on-the-document-is-a-promise-your-platform-cannot-keep-coh</guid>
      <description>&lt;p&gt;In April, Yan, the controller at a six-hundred-person industrial equipment manufacturer, exported four thousand purchase orders for an audit. The auditor scrolled to the middle of the sheet and stopped. PO-2026-0147 was there. PO-2026-0149 was there. 0148 was not.&lt;/p&gt;

&lt;p&gt;She checked the recycle bin, the deleted filter, the archived view. Then she sent me the spreadsheet with one line: "Either somebody deleted a purchase order, or the system invented a number and threw it away. Which one do I tell the auditor?"&lt;/p&gt;

&lt;p&gt;The answer is the second one. It is worse than a bug, because it is the design, and because nobody in the room had ever been told.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number is not in the row
&lt;/h2&gt;

&lt;p&gt;Every data table the platform creates gets two system columns for free. An id, a thirty-two character string, and a seq, an integer described in our own documentation as the order in which records were added.&lt;/p&gt;

&lt;p&gt;The number a customer actually reads — the one printed on the purchase order — is neither of those. It is a separate field with its own PostgreSQL sequence object behind it, named after the application, the table, and the field. On insert, the platform takes the next value from that sequence, runs an expression over it, and stores the result as a string.&lt;/p&gt;

&lt;p&gt;So the number is not a property of the record. It is a token the record was allowed to keep. That distinction sounds academic until the first time you need to explain a missing one.&lt;/p&gt;

&lt;h2&gt;
  
  
  nextval does not roll back
&lt;/h2&gt;

&lt;p&gt;Our documentation says it in a single sentence, buried under the settings table: sequences in PostgreSQL are non-transactional, so numbering may not be continuous when a transaction rolls back, because a value taken from the sequence is never returned.&lt;/p&gt;

&lt;p&gt;Read that as a specification rather than a caveat and you get the whole of 0148. A clerk opened a purchase order, mistyped a supplier code, hit save, and the platform pulled 0148 before the validation rule rejected the form. She fixed the code and saved again, and got 0149. The transaction rolled back. The number did not. There is no row, no audit entry, no trace of the attempt, and a permanent hole in a document series a customer will reconcile against a supplier's ledger for the next seven years.&lt;/p&gt;

&lt;p&gt;We already have the vocabulary for this. A voided invoice keeps its number and is stamped void; the gap is explained by the record. We do the opposite. We erase the number and keep nothing, so the only evidence that anything happened is the absence of evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two settings that have to agree, and nothing that checks
&lt;/h2&gt;

&lt;p&gt;Numbering has exactly two settings. An expression that builds the string, and a reset period: never, daily, monthly, or yearly, each snapping the counter back to one at midnight.&lt;/p&gt;

&lt;p&gt;The date inside the number is written by the expression. The reset is a dropdown. Nothing validates that the two describe the same period, and nothing warns you when they do not.&lt;/p&gt;

&lt;p&gt;Choose monthly reset with a year-only prefix and the first purchase order of February asks for 2026-00001, which January already spent. The uniqueness check — which does run, at insert — refuses the save. The first person to submit a document on the first of the month gets an error about a duplicate number they never typed, and the person they call for help has to reason about a sequence object they cannot see.&lt;/p&gt;

&lt;p&gt;Midnight is its own small hazard: the reset fires at 00:00, and the expression reads the server clock when it executes. A save straddling the boundary can carry yesterday's date with today's counter. Rare, unglamorous, impossible to reproduce on request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The immutable field that is not immutable
&lt;/h2&gt;

&lt;p&gt;The number is stored as a string of up to a hundred and twenty-eight characters. Our documentation states, correctly, that it is computed only on insert and cannot be modified. Two paragraphs later it explains that if you need to modify it, you should call the table update function from a script.&lt;/p&gt;

&lt;p&gt;Both sentences are true. The number is immutable to the person who owns the document and mutable to anyone who can write a script. Our own field-conversion reference is more honest still: the number is computed automatically, stored as a string, and automation or scripts may modify it.&lt;/p&gt;

&lt;p&gt;Which means the one field in the entire platform that leaves the building — printed, scanned, faxed, quoted over the phone, keyed into another company's ERP — is protected by a sentence in a document rather than by a permission. Every other field on that form can be corrected and the correction stays inside. This one has copies that we cannot reach. When a migration script rewrites it to fix a prefix, we are not editing data. We are editing the past.&lt;/p&gt;

&lt;h2&gt;
  
  
  It also sorts like text
&lt;/h2&gt;

&lt;p&gt;Because the stored type is a string, sorting follows string rules. One, ten, one hundred, one hundred and two, two. Yan's export was ordered that way, which made a few real gaps look like a pattern of tampering.&lt;/p&gt;

&lt;p&gt;The fix is a function field that casts the number to a numeric type and sorts on that instead. Most teams add it after their first audit. Nobody adds it before, because in a demo the list has eleven rows and everything looks fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The backup that does not contain the counter
&lt;/h2&gt;

&lt;p&gt;When you export a table's data, you get rows. The sequence lives in a separate object with its own name and its own last value, and a table-level dump does not carry it.&lt;/p&gt;

&lt;p&gt;Our migration guide knows this. It includes a step titled reset sequence value, a single UPDATE against the numbering field's sequence, to be run after the rows are restored. It is one line in a runbook executed at two in the morning by an engineer with forty other lines to get through. Skip it and the counter starts from wherever the target happened to be, and the platform begins issuing numbers that already exist on paper.&lt;/p&gt;

&lt;p&gt;The uniqueness check turns most of those into failed saves rather than duplicates, which is a small mercy and a poor recovery plan. On a date-prefixed scheme crossing a year boundary, it can do something worse: hand out a number that is genuinely new, correctly formatted, and attached to the wrong document.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we built it this way
&lt;/h2&gt;

&lt;p&gt;Because a counter is fast and a ledger is a table with a lock.&lt;/p&gt;

&lt;p&gt;Taking the next value from a sequence is constant time and never contends. Allocating from a ledger row — scoped by document type and period, with a unique index held until commit — contends at exactly the moment every customer submits at once, which is Monday morning.&lt;/p&gt;

&lt;p&gt;So we optimized the path everyone uses and moved the cost to the one afternoon a year when someone asks a question we cannot answer. Gaps are cheap for us and expensive for the customer, and that asymmetry never appeared in a metric, because nobody measures numbers that were never issued.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;The fix is not research. A number table with scope, period, value, record id, and state. Allocation inside the same transaction as the record. A unique index doing the work instead of a check-then-write. Reset by inserting a new period row rather than mutating a counter at midnight. Gaps written down as void, with a reason and a person. And immutability enforced by a permission, not by a paragraph.&lt;/p&gt;

&lt;p&gt;Every one of those makes a create slower and a failure louder. The current design makes both invisible, and invisible is what a demo rewards.&lt;/p&gt;

&lt;p&gt;Yan's auditor accepted the explanation, eventually. What she did not accept, and what I have not been able to argue with since, is that the number on the document is a promise — to a customer, an auditor, a tax authority — and we built it as a counter with a format string attached.&lt;/p&gt;

&lt;p&gt;Fourteen hundred records before 0148 were fine. That is not the point. The point is that we shipped an identifier that leaves the building without ever deciding who owns it, and then wrote a sentence in the documentation admitting it might not be there.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>database</category>
      <category>architecture</category>
      <category>enterprise</category>
    </item>
    <item>
      <title>Two People Edited the Same Record. Your Platform Called It a Successful Save.</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Sun, 18 Oct 2026 01:40:41 +0000</pubDate>
      <link>https://dev.to/informat/two-people-edited-the-same-record-your-platform-called-it-a-successful-save-o0g</link>
      <guid>https://dev.to/informat/two-people-edited-the-same-record-your-platform-called-it-a-successful-save-o0g</guid>
      <description>&lt;p&gt;Monday, 11:52. Rui runs sales operations at a 900-person equipment manufacturer. Her team of six lives inside one quotation table, and that Monday two of them opened the same quote twenty minutes apart.&lt;/p&gt;

&lt;p&gt;The account manager changed the discount tier at 12:40, from list to the volume rate they had agreed with the customer. The sales director opened the same record after one, fixed the delivery address after a phone call, and saved. Both screens showed a quiet confirmation. No warning, no red text. Two successful saves.&lt;/p&gt;

&lt;p&gt;Three weeks later the order was invoiced at list price. The margin report was off by eleven percent, which is how anyone found out. Rui sent me a screenshot of the record history and one sentence: "The system says both of them saved. So where did my discount go?"&lt;/p&gt;

&lt;p&gt;I have been on the platform side of that question for a long time. The answer is embarrassing not because it is complicated, but because it is correct. Nothing failed. That is the whole problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The page is a photograph
&lt;/h2&gt;

&lt;p&gt;When you open a record, the platform hands your browser every field of that row. The form is not a live connection to the row. It is a photograph of the row, taken the moment you clicked.&lt;/p&gt;

&lt;p&gt;Everything you do for the next four hours happens to the photograph. You change two fields, leave the rest as they were when you arrived, and press Save. The platform receives a complete record and writes it back — including the fields you never touched, restored to their 09:40 values.&lt;/p&gt;

&lt;p&gt;The sales director did not overwrite the discount. He never saw its new value, because his copy predates it. He faithfully saved what he believed the record was. The platform received a full, well-formed, permission-checked record, wrote it, and returned success. A correct, authorized, confirmed change was erased by a person who did nothing wrong.&lt;/p&gt;

&lt;p&gt;We have a name for this. Lost update. It has been in the literature since the 1970s. It is not an open research problem. It is a column and a WHERE clause.&lt;/p&gt;

&lt;h2&gt;
  
  
  We already built the lock. We gave it to the scripts.
&lt;/h2&gt;

&lt;p&gt;The platform has row locking. It is exposed on the scripting API's query call, an option that means "lock this row while I hold it." Pessimistic, explicit, real.&lt;/p&gt;

&lt;p&gt;The capability exists, and it belongs to exactly the people least likely to need it. A developer writing a nightly reconciliation can take a lock. A clerk editing a stock adjustment in a form cannot, because there is no lock anywhere in the form's vocabulary — no check-out, no "editing now", no reservation with a timeout attached.&lt;/p&gt;

&lt;p&gt;We built the mechanism for the machine and withheld it from the human. The only actor guaranteed to serialize its own writes is the one that runs on a schedule and never gets interrupted.&lt;/p&gt;

&lt;h2&gt;
  
  
  We already compute the diff. We use it for blame.
&lt;/h2&gt;

&lt;p&gt;Every serious platform keeps a change log. Ours does: a side table per data table, each row holding the record id, the field id, the value before, the value after, who changed it, and when.&lt;/p&gt;

&lt;p&gt;Field. Before. After. Per field, per save. That is not merely an audit trail. That is the exact input a three-way merge needs. Given two saves and an original, you can tell that one party touched the discount and the other the address, that the changed sets do not intersect, and that both should survive.&lt;/p&gt;

&lt;p&gt;We store the diff and use it to assign blame after the damage, instead of using it to prevent the damage. The merge machinery was built, shipped, and pointed at the wrong problem.&lt;/p&gt;

&lt;p&gt;Even the blame is optional. Field-level change logging is a switch, per field, that someone has to remember. Script updates log only if a config flag is set, and it defaults to false. Automation steps have their own toggle, also off unless you turn it on. And two field types most likely to be edited by two people at once — related lists and lookup lists — do not appear in the change log at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The token that could have been a version
&lt;/h2&gt;

&lt;p&gt;Every row already carries a last-modified time and user. On update, if the caller does not supply them, the platform fills them in with now and with the current user.&lt;/p&gt;

&lt;p&gt;The one column that could have served as a version token is a column the client may set. It is not a version. It is a timestamp anyone can overwrite, including the person whose photograph is four hours old.&lt;/p&gt;

&lt;p&gt;And no write path asks for one. The script update takes a table id and a record. The conditional update takes a filter and a record. The HTTP endpoint takes an API key, a table id, a record, and a list of fields. Not one accepts "only if this record is still at version 7." The policy is last write wins, and our contribution is to not mention there was a contest.&lt;/p&gt;

&lt;p&gt;Which produces an inversion I did not expect. The HTTP API requires you to declare, field by field, what you intend to change, so a machine cannot clobber a field it did not name. The form submits everything. The least human path through our platform is the safest one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second copy nobody thinks about
&lt;/h2&gt;

&lt;p&gt;When a process starts, the form's data is copied into process variables. Tasks can generate their own copy, scoped to that task. And when a task is pushed back to an earlier node, that node reads the stored copy back as the form data.&lt;/p&gt;

&lt;p&gt;A requisition sitting in approval for six days is a six-day-old photograph the platform will eventually write back. On day two the requester fixes a quantity. On day five the approver rejects it to the start. The form reopens with day-zero numbers, and the fix is gone — the workflow was a copy of the record, not a view of it.&lt;/p&gt;

&lt;p&gt;There is a configuration flag whose only purpose is to sync the workflow instance's form variables back to the record. It exists because the two copies are permitted to disagree. We wrote the admission into a settings page and left it off by default, like the change log.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we did not fix it
&lt;/h2&gt;

&lt;p&gt;Because conflict resolution asks a question, and a question becomes a support ticket.&lt;/p&gt;

&lt;p&gt;The moment you introduce a version check, some percentage of saves stop succeeding. They return a conflict. Someone must compare two versions and possibly re-enter work. That is a bad afternoon for one user, it is measurable, and it lands on our queue.&lt;/p&gt;

&lt;p&gt;A silent overwrite produces a success. Our dashboards count saves, and saves go up. Nobody counts edits that were erased, because an erased edit leaves no event. The failure is invisible when it happens and surfaces three weeks later in another department's number, next to a changelog entry naming a person who did what their screen told them to do.&lt;/p&gt;

&lt;p&gt;So we optimized for the metric we could see. Not a bug in our engineering — a bug in what we chose to measure.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;What a customer actually needs is small. A version on the row, returned with every read and required on every write. A conflict response instead of a confirmation. A field-level merge when the changed sets do not overlap — which we can compute, because we already store the diffs. A lock a human can take, with a timeout and a name. And a form that says, plainly, that this record changed while you were away, and here is what you are about to write over.&lt;/p&gt;

&lt;p&gt;None of that is novel. Every mechanism existed before low-code did. We did not fail to know about them. We failed to charge for them.&lt;/p&gt;

&lt;p&gt;Our own pitch says that because business data is shared, multiple people will edit the same record, and the answer is that every change leaves a trace — who, when, from what, to what. Read that as an engineer rather than a buyer. A trace is a witness. A witness is what you buy when you have decided not to hire a referee.&lt;/p&gt;

&lt;p&gt;And we made the witness optional.&lt;/p&gt;

&lt;p&gt;The write succeeded. The edit did not survive. We logged nothing, because logging is a setting.&lt;/p&gt;

&lt;p&gt;We have been calling that a successful save for six years.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>concurrency</category>
      <category>architecture</category>
      <category>database</category>
    </item>
    <item>
      <title>The Field Type You Pick in Ten Minutes Is a Ten-Year Decision</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Sat, 17 Oct 2026 01:36:20 +0000</pubDate>
      <link>https://dev.to/informat/the-field-type-you-pick-in-ten-minutes-is-a-ten-year-decision-2hnc</link>
      <guid>https://dev.to/informat/the-field-type-you-pick-in-ten-minutes-is-a-ten-year-decision-2hnc</guid>
      <description>&lt;p&gt;Mei runs procurement at an equipment manufacturer with about 700 people. Their platform went live three years ago. Last month she asked me a question that sounded like a two-minute request.&lt;/p&gt;

&lt;p&gt;"Sales wants to sort the contract amount from high to low. Right now it sorts like text. Can we just change the field type?"&lt;/p&gt;

&lt;p&gt;The field is amount. It was created in the second week of the project, in a workshop room, by an analyst who had a dropdown and a deadline. She picked single-line text, because the number came from a PDF and she was not sure yet whether it needed decimals.&lt;/p&gt;

&lt;p&gt;Three years and roughly nine thousand contracts later, that ten-second choice has become a six-week project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dropdown is not a label
&lt;/h2&gt;

&lt;p&gt;Renaming a field takes a minute. Moving it on a form takes a minute. Changing its type is a different category of work entirely, and almost nobody knows that at the moment they choose.&lt;/p&gt;

&lt;p&gt;In a platform like ours, the type is not metadata that sits above the table. It is the table. Every data table compiles down to a real physical table in PostgreSQL, one column per field, and the field type decides what that column is. Single-line text is a bounded string. Multi-selects and attachments are stored as JSON. There is no neutral layer underneath where a type can be reconsidered later.&lt;/p&gt;

&lt;p&gt;So the analyst was not picking a display format. She was writing DDL.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type decides what the database is allowed to do
&lt;/h2&gt;

&lt;p&gt;The second-order effects show up slowly, in reporting.&lt;/p&gt;

&lt;p&gt;Indexes are the clearest case. Ours can be created on ordinary columns, but the documentation is honest about the exceptions: function fields, related-record fields, lookup fields, static text and custom components cannot be indexed. Which means the moment you model something as a lookup instead of a stored value, you have also decided that this column will never be a fast filter.&lt;/p&gt;

&lt;p&gt;Nobody thinks about that in a workshop. They think about not typing the same supplier name forty times, and they are right to — denormalization for convenience is usually correct. The convenience is just paid for by whoever, two years later, builds a dashboard filtered on that column and watches it time out.&lt;/p&gt;

&lt;p&gt;The type also decides what sorting means. Text sorts lexicographically, so "9" comes after "10", and the amount column becomes useless for the one question finance asks every Monday. A float sorts correctly and rounds incorrectly. A precise decimal sorts and rounds correctly and is the only one of the three that survives a reconciliation.&lt;/p&gt;

&lt;p&gt;These are ordinary database distinctions. What is unusual is that in a low-code platform, the person making the decision was never taught them, and the interface does not teach them either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type decides how many states the world has
&lt;/h2&gt;

&lt;p&gt;My favourite example is the checkbox.&lt;/p&gt;

&lt;p&gt;A boolean has two states. A procurement approval has three: approved, rejected, and nobody has looked at it yet. Model it as a checkbox and the third state does not disappear — it becomes null, which is indistinguishable from "the field was added after this record was created" and from "the form did not require it".&lt;/p&gt;

&lt;p&gt;The platform will not save you here either. Generated tables ship with exactly one not-null column, the system id; every business column is nullable by default, because nullability is a form setting rather than a storage decision. So the third state exists physically and is unnameable semantically. Reporting on it becomes a negotiation: does blank mean no, or does blank mean unknown? Two analysts will answer differently, and both will be able to point at real rows.&lt;/p&gt;

&lt;p&gt;A single-select with three options costs the same ten seconds and keeps the third state.&lt;/p&gt;

&lt;p&gt;The type is a statement about the business. We treat it as a statement about the widget.&lt;/p&gt;

&lt;h2&gt;
  
  
  Type is a promise to every consumer downstream
&lt;/h2&gt;

&lt;p&gt;The database is only the first reader. By year two, a field has many more.&lt;/p&gt;

&lt;p&gt;The WebAPI returns what is stored, so a JSON-encoded multi-select becomes a parsing problem for every integration that touches it. The Excel export renders it one way, the dashboard another, the mobile app a third. The importer expects a template that matches the current type, and every historical spreadsheet your team has been emailing around matches the old one.&lt;/p&gt;

&lt;p&gt;Expressions are the quietest and most expensive reader. Formulas reference fields and coerce values, and when the type changes, the coercion changes underneath the formula without the formula changing at all. A comparison that meant one thing when both sides were strings means something else when one side becomes a number. Nothing errors. The report just starts disagreeing with last quarter.&lt;/p&gt;

&lt;p&gt;And when the type finally changes, it is not an alter table. It is a data rewrite, plus every expression that touched the field, plus every view and dashboard and export template, plus every integration contract, plus the import templates stored in someone's shared drive. The rewrite cannot even be proven lossless, because the old column had no constraints: it contains values that were never valid for the type you are moving to, and somebody has to decide, row by row, what "N/A", "TBD", "1.2M" and "-" were supposed to mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The economics are backwards
&lt;/h2&gt;

&lt;p&gt;Here is what bothers me most.&lt;/p&gt;

&lt;p&gt;Adding a field is cheap and removing one is politically expensive. So platforms are optimized, culturally and technically, for addition. Every project accumulates fields. Each one is a type decision made by whoever was closest to the requirement that day, usually under time pressure, usually without knowing what the reports would need.&lt;/p&gt;

&lt;p&gt;The decisions that take the least time have the longest half-life. A workflow can be redesigned in a week. A permission model can be tightened in an afternoon. A field type, once nine thousand records depend on it, is a migration project with a steering committee.&lt;/p&gt;

&lt;p&gt;We have built an interface where the cheapest action is the most permanent one, and we have not labelled it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the platform owes the person at the dropdown
&lt;/h2&gt;

&lt;p&gt;Some of this is unavoidable. Types must be types, and a platform that pretends otherwise is lying.&lt;/p&gt;

&lt;p&gt;But there is a difference between a hard decision and an invisible one.&lt;/p&gt;

&lt;p&gt;A field type should show what it forecloses at the moment of choosing: whether it can be indexed, how it sorts, how it will appear in exports and in the API, and what changing it later will cost. Not a warning dialog on every field — that becomes noise. A single line, in the same place where you pick the type, saying what you are giving up.&lt;/p&gt;

&lt;p&gt;A type change should be an operation with a plan rather than a quiet edit: show every expression, view, export and integration that reads the column; run the conversion in dry mode against real values; tell me how many rows will fail to parse before I click. We do the opposite. We let the change succeed and let the failures surface later, as a report that no longer adds up.&lt;/p&gt;

&lt;p&gt;And a platform should treat a small number of fields as architecture rather than content. Amount, date, status, owner, and the identifier that everything joins on. Those five deserve a review before go-live, in the same way a schema review happens in a codebase. Not because they are hard to name, but because they are expensive to be wrong about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;The tempting fix is to make types flexible: store everything as text, parse at the edges, let each application decide what the value means.&lt;/p&gt;

&lt;p&gt;That does not remove the decision. It moves it to every consumer, and it removes the one thing a type was giving you — a single, enforced, queryable definition of what the column contains. You get a platform that never blocks a migration because it never knew what the data was.&lt;/p&gt;

&lt;p&gt;The real failure is not that the analyst chose text for an amount. Given what she knew in week two, text was defensible.&lt;/p&gt;

&lt;p&gt;The failure is that the platform let a ten-second decision stay invisible for three years, and then presented the bill as a six-week project nobody had budgeted for.&lt;/p&gt;

&lt;p&gt;We are very good at making the first ten minutes feel effortless.&lt;/p&gt;

&lt;p&gt;We have never once shown the customer what the next ten years cost.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>database</category>
      <category>architecture</category>
      <category>data</category>
    </item>
    <item>
      <title>Your Low-Code Platform Has a Calendar, Not a Queue.</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Fri, 16 Oct 2026 01:45:23 +0000</pubDate>
      <link>https://dev.to/informat/your-low-code-platform-has-a-calendar-not-a-queue-4bj3</link>
      <guid>https://dev.to/informat/your-low-code-platform-has-a-calendar-not-a-queue-4bj3</guid>
      <description>&lt;p&gt;The spreadsheet arrived on a Tuesday in August, from a supply chain analyst at a regional pharmacy chain, and it was mostly gray. Ninety rows, one for each night of the previous quarter. Forty-one of them were highlighted in yellow.&lt;/p&gt;

&lt;p&gt;She had spent six weeks reconciling those nights by hand, and she had narrowed the complaint down to one sentence: "It's not that the numbers are wrong. It's that they're late by exactly one day."&lt;/p&gt;

&lt;p&gt;I wrote that replenishment sync two years ago, so I opened the job log expecting an error. There wasn't one. There never is.&lt;/p&gt;

&lt;p&gt;Every night at 00:30 the platform started an automation that called an ERP export, recalculated reorder points and wrote them back. On the good nights it finished in nineteen minutes. On the yellow nights the export took longer, and something did not happen. No failure, no retry, no record of a decision. On forty-one nights out of ninety, work that was supposed to happen simply didn't, and the only witness was a human being with a calculator.&lt;/p&gt;

&lt;h2&gt;
  
  
  The interval is not a schedule. It is a hope.
&lt;/h2&gt;

&lt;p&gt;Here is the sentence I had read years earlier and not understood, from the platform's own scheduler documentation: the precision of a scheduled task is one minute, so every execution carries zero to sixty seconds of delay — and at any given moment, only one execution of a scheduled task is running. Which means that if a task configured to run every five minutes takes six minutes, the execution planned for the fifth minute is cancelled.&lt;/p&gt;

&lt;p&gt;Cancelled. Not queued, not deferred, not marked as skipped. It is removed from the future as though it had never been scheduled, and the calendar simply continues from the new position.&lt;/p&gt;

&lt;p&gt;Read against a demo, that sounds reasonable — obviously you do not want two copies of the same job running at once. Read against a business, it is something else. The schedule we configured was "every night at 00:30". The schedule that actually ran was "every night at 00:30, unless the previous night was slow, in which case sometimes not". The interval between two runs was not a decision anyone made. It was a remainder: whatever was left over after the work took as long as it took.&lt;/p&gt;

&lt;p&gt;Then subtract the other half of the promise. A "one minute" scheduler with up to sixty seconds of drift means "every five minutes" is really "every five to six minutes, chosen by a scheduler you do not control". Nobody is harmed by that on a good night. But the analyst's complaint was about nights, and her nights were being silently merged and silently dropped while the configuration screen kept displaying the tidy version of the schedule.&lt;/p&gt;

&lt;p&gt;There is no state called "skipped". So there was nothing to show her.&lt;/p&gt;

&lt;h2&gt;
  
  
  The only queue you have is a number in a file.
&lt;/h2&gt;

&lt;p&gt;Six weeks later the same shape appeared as a support ticket from a mid-size manufacturer in Dongguan. Their statement and reminder runs had stopped entirely for nine days during their busiest quarter. No failure notifications, no red rows. The jobs had not errored. They had not run.&lt;/p&gt;

&lt;p&gt;The documentation's troubleshooting page for "scheduled tasks stopped running" is unusually candid, and it lists the causes: the scheduling thread pool is too small to pick up the work, a task's next-run time fails to update, archived or disabled state filters tasks out, or a misfire path interrupts subsequent scheduling. The remedy is to raise the scheduling pool size in a server configuration file, restart, and then hunt for the stalled work with a SQL query — the one that finds scheduled rows whose status is still Pending and whose next-run time is already in the past.&lt;/p&gt;

&lt;p&gt;That query is the most honest line in the entire manual, because it reveals what the platform's model of a backlog actually is. It is a database table.&lt;/p&gt;

&lt;p&gt;There is no queue view. No depth gauge. No "twelve waiting, three running, one given up on". The word the scheduler uses for a job that has not run yet is the same word it uses for a job that should have run twenty minutes ago. Pending is doing two very different jobs, and only one of them is visible in the interface.&lt;/p&gt;

&lt;p&gt;So the throughput ceiling of that customer's nightly operations was a default number in a file they had never opened, discoverable only by reading a troubleshooting page about tasks that do not run, and raisable only by someone with shell access. We sell a platform where a business analyst can build an entire application without ever seeing a terminal. And then the arithmetic of how much work that application can absorb per minute lives in YAML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every misfire policy is a bet placed before you know the odds.
&lt;/h2&gt;

&lt;p&gt;The platform does offer one piece of genuine queue thinking: a misfire policy, for tasks that miss their window. There are two choices. Run everything that was missed, as soon as possible. Or discard everything that was missed and wait for the next occurrence.&lt;/p&gt;

&lt;p&gt;Both are defensible. Neither is a strategy.&lt;/p&gt;

&lt;p&gt;Catch-up means that after a maintenance window or a long weekend you get every overdue execution arriving at once, competing for the same small pool — precisely the condition that caused the misses. Discard-all means the platform quietly forgets work a business already promised someone. And the resulting hole is never labeled a hole; it looks exactly like a night on which there was simply nothing to do.&lt;/p&gt;

&lt;p&gt;Worse, the choice has to be made per task, at design time, before you know which failure mode will hurt. You cannot know whether the missed run was a redundant refresh or the only transaction in the month that mattered. That is not a setting. That is a guess, written into the metadata as though it were a fact.&lt;/p&gt;

&lt;p&gt;Notice also what the scheduler forbids: a scheduled execution may not contain interactive steps. That rule is correct — nobody should be waiting for a human at 00:30 — but it means every judgement call inside a batch job becomes either a guess or an abort. Both arrive as data. Neither arrives as an alert.&lt;/p&gt;

&lt;h2&gt;
  
  
  The retry mechanism is a person.
&lt;/h2&gt;

&lt;p&gt;So what did both customers do? They rebuilt the missing queue out of the only materials available: their own calendars and their own attention. The pharmacy analyst wrote a manual rebuild procedure for every yellow night. The manufacturer's IT team ran the missed statements by hand each morning until someone finally asked why they were doing it.&lt;/p&gt;

&lt;p&gt;That is the real cost — not the lost execution, but the fact that the platform has no way to represent unfinished work, so unfinished work is stored in human memory and in the habit of checking. A job is either running or it is nothing. Between those two states lies a whole territory — waiting, deferred, failed, retried, abandoned — and every square inch of it has been outsourced to the person who depends on the outcome.&lt;/p&gt;

&lt;p&gt;It is also why batch jobs on platforms like ours get written defensively, with "have I already processed this window" checks buried in the script. Idempotency should be something the runtime gives you; here it is the tax you pay for the scheduler's silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;We call it scheduling because a calendar is the metaphor business people already understand, and the metaphor is the entire problem. A calendar answers exactly one question — when does this happen next — and there is a place on a screen where you can draw that answer. What an operations team actually needs is a queue: work items that persist, that carry a state you can see, that can be claimed, retried, paused, given up on, and counted.&lt;/p&gt;

&lt;p&gt;Every queue in the world can answer "how much is waiting?". Ours can only answer "when will it next run?", which is the wrong question on the morning the numbers are wrong. The pharmacy did not need a better cron expression from me. She needed to open a screen and see that forty-one nights had been cancelled.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is that we did the arithmetic and chose the calendar anyway. A calendar is a row of times. A queue is a list of numbers that grow — and numbers that grow invite questions we would have had to answer. How deep. Since when. For whom. Which ones gave up.&lt;/p&gt;

&lt;p&gt;So we shipped the clock and left the bookkeeping to whoever reconciles on Tuesday.&lt;/p&gt;

&lt;p&gt;The calendar was the demo. The queue was the product.&lt;/p&gt;

&lt;p&gt;We shipped the demo.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>automation</category>
      <category>architecture</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Longest Transaction in Your Low-Code Platform Is the One Nobody Designed</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Wed, 14 Oct 2026 02:46:53 +0000</pubDate>
      <link>https://dev.to/informat/the-longest-transaction-in-your-low-code-platform-is-the-one-nobody-designed-1835</link>
      <guid>https://dev.to/informat/the-longest-transaction-in-your-low-code-platform-is-the-one-nobody-designed-1835</guid>
      <description>&lt;p&gt;The first message was three words long: "Saving is frozen."&lt;/p&gt;

&lt;p&gt;It came from a production planner at a components manufacturer, forwarded by her IT lead with no commentary. She had opened a work order, changed one quantity, and pressed save. The button had been spinning for eleven minutes. She tried it three times, then stopped, because the second attempt made her nervous.&lt;/p&gt;

&lt;p&gt;By the time we got on a call, two things were true. Her save had not failed — it was still running. And nobody else in the plant could edit that work order, or the four records downstream of it. The system was not down. It was busy in a way that looked exactly like being down.&lt;/p&gt;

&lt;p&gt;The cause took an afternoon to find and four seconds to explain. The work order's automation recalculated a bill of materials, looping over every component line and calling a partner API for current pricing. Nine hundred lines, roughly three seconds per call. Forty-five minutes of work, wrapped in a single database transaction, holding locks the whole time, because of a boundary nobody had ever drawn.&lt;/p&gt;

&lt;h2&gt;
  
  
  The transaction is bigger than the drawing
&lt;/h2&gt;

&lt;p&gt;In our platform, the transaction boundary is not something the builder chooses.&lt;/p&gt;

&lt;p&gt;An automation is one transaction. All of it. The documentation is unambiguous — a complete automation runs inside a single transaction, and if anything throws, or the flow is stopped, the whole thing rolls back. It goes further: the before-create event, the creation, and the after-create event of a single step are also one transaction, so aborting anywhere in that chain discards the creation itself. The listener model matches. Listeners — the rules that fire on record creation, on data change, on user action — execute in the same transaction as the thing that triggered them. A listener that throws rolls back the form submission that woke it up.&lt;/p&gt;

&lt;p&gt;None of that is wrong. Atomicity is not a bug. But look at what it means in a visual builder. The person who drew that recalculation was drawing logic — sequence, branches, API responses. They were not drawing a transaction, and nothing on the canvas told them they were. The transaction is inferred from the shape of a graph they built for an entirely different reason.&lt;/p&gt;

&lt;p&gt;The graph is precise. The transaction is a side effect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The number nobody computes
&lt;/h2&gt;

&lt;p&gt;Our own documentation contains the clearest illustration of this failure I have read, and it is offered as friendly advice.&lt;/p&gt;

&lt;p&gt;The example is two data tables, two thousand records each, and a nested loop with an HTTP request at the innermost level. Average request time: three seconds. The documentation does the arithmetic honestly — two thousand times two thousand times three seconds is more than thirty hours — then says what you would expect, that a flow like this running frequently is a disaster for the system, and recommends estimating execution time before you build.&lt;/p&gt;

&lt;p&gt;That paragraph is correct and I am glad it exists. It is also a confession. A thirty-hour transaction is not something the platform prevented. It is something the platform documented. The canvas renders a nested loop as a modest little box, the same size as a single API call. There is no meter, no warning, no estimate, no ceiling. We outsourced the work of noticing to the person with the least information about what it costs.&lt;/p&gt;

&lt;p&gt;There is a transaction timeout in the API — thirty seconds, if you set it — but you can only set it from script. Which brings us to the real problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Everything that governs a transaction lives where the builder cannot see
&lt;/h2&gt;

&lt;p&gt;Our script API for transactions is more complete than I remembered. Seven propagation modes, including nested and requires-new. Isolation levels from read-uncommitted up to serializable. A timeout. A read-only flag. Transaction synchronizations — hooks that fire before commit, after commit, after completion — exactly the mechanism you would use to send a notification after the data is safe rather than inside the lock.&lt;/p&gt;

&lt;p&gt;There is even a name for the transaction, and the documentation explains its purpose: a name helps developers identify and track transactions in logs and monitoring tools.&lt;/p&gt;

&lt;p&gt;Count how many transactions in a real customer application have names. Approximately none. Not because the feature is missing, but because there is no moment in the visual workflow where a transaction becomes a thing a person is asked to think about. It is a constructor you must know exists before you can name it, in a document you only read after something has already gone wrong.&lt;/p&gt;

&lt;p&gt;The one hint that reliably reaches builders is a tip telling them to paste three lines of script if they want a step to open a new transaction — a curious way to teach someone that they were inside one all along.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scheduler has one lane, and it deletes the future
&lt;/h2&gt;

&lt;p&gt;Transactions do not only come from user actions. They come from scheduled work, and the scheduling model has the same shape of problem.&lt;/p&gt;

&lt;p&gt;Scheduled tasks fire at one-minute precision, and at any given moment exactly one is running. Consider a job configured every five minutes that takes six to finish. The run scheduled for minute five does not queue and does not wait. It is cancelled. A slow job does not merely finish late; it quietly accumulates runs that never happened. And after an outage, the misfire policy offers two choices: execute everything that was missed, or throw all of it away. Neither answers the question, which is whether a job that runs long should be allowed to run long, run chunked, or run at all.&lt;/p&gt;

&lt;p&gt;The first time I watched a nightly reconciliation silently lose two cycles because one cycle was slow, I stopped thinking of schedules as triggers and started thinking of them as a single-lane road with no shoulders.&lt;/p&gt;

&lt;h2&gt;
  
  
  Termination is a person, and it is a destructive act
&lt;/h2&gt;

&lt;p&gt;The runtime page does try. It lists what is executing right now — scripts, scheduled jobs, automations, API calls — and lets you terminate one by hand. The documentation names the reasons you would need to. Long-running tasks. Slow execution. Infinite loops. Database deadlocks.&lt;/p&gt;

&lt;p&gt;Read that list again as a design document. Those are the system's own admitted failure modes, and the remedy for all four is a human noticing and clicking a button. Now consider what the button does. It does not pause and it does not checkpoint. It rolls back — which can mean discarding forty-five minutes of calculation, plus every record a colleague created inside a listener that shared the transaction, plus the save the user believed had succeeded.&lt;/p&gt;

&lt;p&gt;A platform whose only answer to "this is taking too long" is "have someone destroy it" has not finished designing the feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Abort is the only word we gave you for no
&lt;/h2&gt;

&lt;p&gt;Because listeners share the trigger's transaction, the way you implement a business rule at the data layer is to abort. Rule violated, flow stopped, everything rolls back. There is no vocabulary for reject this field but keep the record, no partial acceptance. The state machine has two states: it all happened, or none of it did. Builders who need a third state invent it outside the platform, and the validation they were asked to implement quietly becomes a reliability hazard instead of a guardrail.&lt;/p&gt;

&lt;p&gt;And when a script reaches across to an external ERP over a raw JDBC connection, holds it in manual-commit mode, writes to both systems, and commits once — there is no coordinator. Two databases, one commit call, no two-phase protocol. The transaction that spans two systems is the most optimistic object in the building.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;Every other finite resource here has a visible number. Storage is gigabytes. Tables have row counts. Usage points are metered and show up on an invoice. Customers can see the ceiling and argue about where it should be.&lt;/p&gt;

&lt;p&gt;Transaction duration has no number at all, because on the canvas a transaction is not a thing that exists. It is a consequence of a diagram. So the most expensive decision in the system — how long we hold a lock on your data, and therefore how many other people are standing behind you — is a decision nobody is ever asked to make.&lt;/p&gt;

&lt;p&gt;We moved application development up the stack, into a canvas a process analyst can read. Transactions stayed down in milliseconds and isolation levels and lock waits. We never built the bridge.&lt;/p&gt;

&lt;p&gt;Which is why "why is saving frozen?" took an afternoon to answer out of pieces that were all in the documentation. The platform knew.&lt;/p&gt;

&lt;p&gt;The most expensive thing in a low-code platform is not a feature you chose not to build. It is a duration you never knew you were choosing.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>database</category>
      <category>architecture</category>
      <category>performance</category>
    </item>
    <item>
      <title>The Log You Need Is the One the Platform Decided Not to Keep</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Tue, 13 Oct 2026 02:21:43 +0000</pubDate>
      <link>https://dev.to/informat/the-log-you-need-is-the-one-the-platform-decided-not-to-keep-2bfc</link>
      <guid>https://dev.to/informat/the-log-you-need-is-the-one-the-platform-decided-not-to-keep-2bfc</guid>
      <description>&lt;p&gt;The support ticket had been open for nine days when the operations manager finally said the sentence out loud: "It works every time I try it. It only breaks for her."&lt;/p&gt;

&lt;p&gt;It was a shipping approval. The user would submit a form, a script would stamp a batch number, an automation would push the record to a partner system, and a notification would tell the warehouse to move. Four moving parts, three of them invisible, and one user out of a hundred and forty for whom the whole chain produced nothing at all. No error on screen. No failed record. Just silence where a batch number should have been.&lt;/p&gt;

&lt;p&gt;Nine days is not a long time for a bug like that. It is a long time when nobody can see anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The promise is already in the documentation
&lt;/h2&gt;

&lt;p&gt;If you read our platform's own documentation, this is a solved problem. Under Easy Maintenance it says, plainly, that the platform "provides detailed logs and monitoring functions to help quickly locate and solve problems."&lt;/p&gt;

&lt;p&gt;I wrote a version of that sentence. I still think it was true when we wrote it. What it hides is a distinction that took us years of customer escalations to feel properly: a log is a record of what happened. An answer is a statement about what should have happened and where reality diverged. Platforms are generous with the first and stingy with the second.&lt;/p&gt;

&lt;p&gt;Here is what the logs actually look like on the ground.&lt;/p&gt;

&lt;h2&gt;
  
  
  The log tells you what you printed, not what happened
&lt;/h2&gt;

&lt;p&gt;Server-side scripts here run on an embedded engine with a fixed entry file, and they can print. There is exactly one way to do it. The documentation is unusually blunt: printing goes through a single function, the other JavaScript logging functions are not supported, and long objects sometimes come out with an ellipsis that hides the part you needed. The official workaround is to stringify the object yourself before printing it.&lt;/p&gt;

&lt;p&gt;That is a real constraint, and I understand why it exists — you cannot let arbitrary script code write to an unbounded console. But notice what it means. The platform does not log what the script &lt;em&gt;did&lt;/em&gt;. It logs what the developer &lt;em&gt;thought to say&lt;/em&gt;. If the person who wrote the script never imagined that a record could arrive with an empty batch field, the log will never mention the batch field. The log is a transcript of the author's imagination, and the bug is usually hiding exactly where the imagination ran out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Levels are a setting, and production is the wrong setting
&lt;/h2&gt;

&lt;p&gt;Then there is the level question. Log level is an application-wide setting, not a per-module one. Development is where you set it talkative. Production is where the same documentation recommends the opposite — quieter, for performance reasons, which is a fair reason.&lt;/p&gt;

&lt;p&gt;Roll those two facts together and you get the failure mode our customer lived through. The verbose lines exist in development, where nothing goes wrong, and they are filtered out in production, where everything does. The one environment that generates the bug is the one configured not to describe it. We shipped a platform that keeps its notes in a language that is only spoken on Tuesdays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three more ways to lose the evidence
&lt;/h2&gt;

&lt;p&gt;The real-time log window keeps only the most recent thousand entries, and truncates any single entry past a fixed size. A thousand entries sounds like plenty until you remember that automations fire in loops, that one import can generate thousands of event lines, and that the entry you want is by definition the one that scrolled off first. The window is a rear-view mirror with a hundred meters of visible road.&lt;/p&gt;

&lt;p&gt;History goes to files instead — but the files are organized per application, a couple per day, with a bounded retention window. Read that layout out loud and its assumption becomes obvious: an application is the unit of troubleshooting. Our platform's entire pitch is that a business action crosses applications. A form in one app, a script in another, an automation in a third. When that chain stumbles, its evidence is scattered across three directories with three different clocks. Every support engineer on my team has, at some point, opened two log files side by side and tried to line up timestamps by eye. That is not a workflow. That is archaeology with a browser tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  The platform knows what is running. It does not know why
&lt;/h2&gt;

&lt;p&gt;To be fair, we do better than most on the live view. There is a runtime page where you can see scripts, scheduled jobs, automations and API calls that are currently executing, and you can terminate one by hand. It is genuinely useful.&lt;/p&gt;

&lt;p&gt;But look at what it answers. It tells you &lt;em&gt;what&lt;/em&gt; is running. It does not tell you who or what started it, what it was started &lt;em&gt;for&lt;/em&gt;, or what it passed downstream. When a user calls in and says her approval did not move, the runtime view cannot be searched by her action. There is no handle that ties the form submission, the script run, the automation step and the notification attempt into one thing with one name. Each layer knows its own name and nothing else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What one name would have bought us
&lt;/h2&gt;

&lt;p&gt;I want to be concrete here, because "better observability" is the kind of phrase that means nothing.&lt;/p&gt;

&lt;p&gt;A single identifier, minted at the business event and carried through every hop, would have ended that nine-day ticket on day one. You would search the identifier, and instead of a thousand log lines you would get four statements: form accepted; script ran and produced this number; automation attempted delivery and got this response; notification queued for this person. Four statements. One page. The operations manager would have known before lunch that the break was in the delivery attempt and not in the user's account.&lt;/p&gt;

&lt;p&gt;Everything else follows from that handle. Structured events instead of printed sentences, so a filter can find every failed delivery rather than every line containing the word "error." Per-execution retention: talkative while a record is young and cheap to delete later, quiet once it is old. And a trace a customer-success person can read without a developer standing behind them translating.&lt;/p&gt;

&lt;p&gt;None of this is exotic. It is the ordinary kit of a system that expects to be operated. We built the platform first and the operations tooling second, and the order shows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;The hard part of debugging a low-code platform is not that the internals are complicated. It is that the platform was designed by people who also &lt;em&gt;build&lt;/em&gt; things, and builders debug with an editor and a rerun. So we optimized the logs for a developer who is already holding the whole mental model in their head — and then handed the platform to customers who are holding nothing, who have one phone call, no reproduction, and a business process that is stuck.&lt;/p&gt;

&lt;p&gt;Every log line we kept was chosen by someone who assumed the reader would be us. That assumption was correct for about two years and wrong forever after.&lt;/p&gt;

&lt;p&gt;A platform's logs are not for the people who built it. They are for the people who have to keep it running on a Tuesday afternoon when the batch number does not appear. If you cannot answer their question in one search, you have not logged enough. You have just written a lot down.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>debugging</category>
      <category>architecture</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Bad Data in Your Platform Was Created by Your Platform.</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Mon, 12 Oct 2026 01:49:04 +0000</pubDate>
      <link>https://dev.to/informat/the-bad-data-in-your-platform-was-created-by-your-platform-ljn</link>
      <guid>https://dev.to/informat/the-bad-data-in-your-platform-was-created-by-your-platform-ljn</guid>
      <description>&lt;p&gt;Lin runs supply chain at a 400-person manufacturer. Eight months after go-live, she sent me a screenshot: 1,914 purchase orders, every one of them with an empty supplier field.&lt;/p&gt;

&lt;p&gt;The supplier field is required. It has been required since the first week of the project.&lt;/p&gt;

&lt;p&gt;Her message was one line long. "The form will not let me save this. So who saved it?"&lt;/p&gt;

&lt;p&gt;I knew the answer before I opened the file. The platform saved it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The form is one door
&lt;/h2&gt;

&lt;p&gt;When people describe validation in a low-code platform, they describe the form. The official feature list does the same thing: data validation is listed inside the Form Designer, next to conditional display and dynamic calculation. That is where it is documented, and that is where almost every builder puts it.&lt;/p&gt;

&lt;p&gt;It is also, statistically, the least used way data gets into a table.&lt;/p&gt;

&lt;p&gt;Count the doors on a platform that has been running for a year: the form, the Excel importer, the public WebAPI, a scheduled script, a workflow transition that updates related records, an inbound webhook from the ERP, an AI agent that adds and edits records, and the CSV somebody's intern pasted in during the migration weekend.&lt;/p&gt;

&lt;p&gt;Eight doors. One lock. And the lock is on the door that requires a human to be sitting at a desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation on the import path is a checkbox
&lt;/h2&gt;

&lt;p&gt;This is the part that surprises people. The importer has validation too — but it is off until somebody turns it on.&lt;/p&gt;

&lt;p&gt;Open the import control configuration and you get an option to enable data validation for imported rows, then a validation expression you write yourself, then a failure message, then two more switches: whether to keep importing rows that fail, and whether to skip the current row instead. The default posture is permissive. The expression is authored by whoever configured that particular button, on that particular day, for that particular table.&lt;/p&gt;

&lt;p&gt;So the same supplier field the form refuses can be written 1,914 times by an importer nobody remembers configuring. The rule now exists in two places, with two authors and no shared source of truth. Your form says the supplier is mandatory. Your import button says the supplier is mandatory if someone ticked a box three years ago.&lt;/p&gt;

&lt;p&gt;The interesting question is not why the data got in. It is which of the two is the real rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The database does not agree with either of them
&lt;/h2&gt;

&lt;p&gt;Most platforms could enforce more than they do. Ours generates a real physical table per data table, DDL and all. The platform owns every column. It could push constraints down.&lt;/p&gt;

&lt;p&gt;It mostly does not. Read the storage documentation and you will find that the generated table has exactly one not-null column: the system id. Every business column ships nullable, because nullability is a form setting, not a storage decision. The one clear exception is uniqueness, and uniqueness genuinely lives at the database layer, implemented as an index. The docs are explicit that a violation is rejected on create or on update.&lt;/p&gt;

&lt;p&gt;That asymmetry is worth sitting with. The one rule that lives below the application is the one rule the application cannot fake. Everything else — required, range, format, date window — lives above the database, inside a layer that only some writes pass through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules are moving too
&lt;/h2&gt;

&lt;p&gt;Lin's 1,914 rows were not created under the current rules. They were created under whatever rules existed the last time each row was touched.&lt;/p&gt;

&lt;p&gt;Required is a switch on the field. Conditional-required is an expression somebody wrote. Format checking exists on single-line text — email, URL, phone number, ID number — but not on the field type next to it. Integer ranges apply only if someone enabled the range option. Uniqueness needs an index that someone had to create deliberately. Date restrictions are configured field by field.&lt;/p&gt;

&lt;p&gt;None of this is wrong. Each one is a reasonable product decision made by a reasonable person at a reasonable moment.&lt;/p&gt;

&lt;p&gt;The consequence is that your rule set has a version history and your data does not. A row created in March was validated against March's rules. Tighten a rule in June and you have silently split one table into "born valid" and "born invalid" — and no column tells you which is which. You find out eight months later, from a human, in a screenshot.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scripts, workflows, and agents enforce intent, not shape
&lt;/h2&gt;

&lt;p&gt;Then there is the layer that writes data on purpose instead of by hand. The script API exposes direct record updates and batch updates, and the documentation does not promise that form rules run. Some of those calls carry explicit switches for whether to write a change entry and whether to recompute rollup fields. Those switches exist because someone needed a write to be faster, quieter, or both.&lt;/p&gt;

&lt;p&gt;Permissions do not close this gap. When an AI agent edits records it acts with the permissions of the calling user. That answers who is allowed to write. It says nothing about whether what was written is valid. Authorization and validity are different questions, and low-code platforms are excellent at the first and informal about the second.&lt;/p&gt;

&lt;p&gt;Write paths multiply because the platform is successful. Every integration, every automation, every agent is another door — and each new door is added by someone who has never opened the field's validation settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nobody owns the repair
&lt;/h2&gt;

&lt;p&gt;Here is where the problem actually lives, and it is not a validation problem.&lt;/p&gt;

&lt;p&gt;When dirty data appears, three fixes are available: fix the data, fix the rule, or fix the door. Choosing between them requires knowing who owns the rule and what the rule promises. Most platforms can tell you who changed a record — and often only if change tracking was enabled on that table in the first place. Very few can tell you who owns a rule, when it took effect, or what it replaced.&lt;/p&gt;

&lt;p&gt;The platform's own data modeling guidance hints at this. It says every important field needs a definition, a type, a required rule, an accepted range, and an owner — and then, in the FAQ, that the model should be reviewed "when data-quality issues reveal unclear ownership or definitions." That is the vendor admitting the real signal is ownership, not coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;The instinct is to add more validation. It is the wrong instinct, and it makes things worse.&lt;/p&gt;

&lt;p&gt;Every rule you add to the form widens the gap between the doors, because you only ever add it to the door you use. The importer, the script, the webhook and the agent keep writing under their own rules, which are now measurably older than yours. You will have made the form stricter and the table no cleaner.&lt;/p&gt;

&lt;p&gt;A platform cannot make bad data impossible by adding a checkbox to a screen. What it can do is make bad data visible and repairable: treat a rule as an entity with an owner, a version and a scope; apply it at every write path instead of the polite one; and make it possible to re-run that rule over history, so "born invalid" is a query rather than a discovery.&lt;/p&gt;

&lt;p&gt;You did not have a data quality problem.&lt;/p&gt;

&lt;p&gt;You had eight front doors and one lock.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>data</category>
      <category>architecture</category>
      <category>database</category>
    </item>
    <item>
      <title>The Template You Copied Now Owns You.</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Sun, 11 Oct 2026 01:38:28 +0000</pubDate>
      <link>https://dev.to/informat/the-template-you-copied-now-owns-you-2jpm</link>
      <guid>https://dev.to/informat/the-template-you-copied-now-owns-you-2jpm</guid>
      <description>&lt;p&gt;Eighteen months ago I was in a meeting room in Dongguan with the quality manager of a precision parts manufacturer. She had one form on the screen — the incoming inspection sheet for Line 1 — and she wanted a second one for a new production line. "It is almost the same," she said. "Can you just copy it?"&lt;/p&gt;

&lt;p&gt;I said yes. The copy took about four seconds. The consequences took a year and a half.&lt;/p&gt;

&lt;p&gt;This is the story of the most under-priced feature in every low-code platform I have worked on: reuse. We market it relentlessly. Industry templates. Component libraries. App packages you install with a single click. Platforms advertise "development and reuse of custom UI components" as a headline capability, right next to the form designer. And every time we ship a copy button, we quietly create a dependency graph that nobody ever draws.&lt;/p&gt;

&lt;h2&gt;
  
  
  The copy button is the most dangerous thing we ship
&lt;/h2&gt;

&lt;p&gt;Copying is the cheapest thing a platform can offer and the most expensive thing a customer can do. At the moment of copying it is pure convenience: the fields are already there, the validation is already written, the layout is already approved. Six months later, the vendor introduces a new required compliance field. Now there are eleven copies of that form, and the person who made them no longer works at the company.&lt;/p&gt;

&lt;p&gt;The platform's own reference material is honest about the intent — templates are described as a &lt;em&gt;starting point&lt;/em&gt; you then customize. That word, customize, is doing an enormous amount of unacknowledged work. Customization is exactly the moment the copy stops being related to its parent. The template gave you a head start. It also gave you a fork.&lt;/p&gt;

&lt;p&gt;There is a real distinction here and most platforms blur it. Referencing means many apps share one definition that is updated once. Copying means many apps hold their own frozen definition. Referencing is hard to build and hard to sell, because it has visible friction at t=0. Copying is trivial and demo-friendly. So we build the copy button, describe it in the same breath as a shared component library, and let the customer discover the difference in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  We are almost always reusing the wrong unit
&lt;/h2&gt;

&lt;p&gt;Ask what actually gets reused across enterprise systems and you get an awkward answer. It is rarely the screen. It is the rule — the validation that a batch number must match the receipt, the rule that only a certified inspector can sign off a line, the rule that a deviation above two percent routes to a manager. Those rules are policy, and policy is the thing every plant genuinely agrees on.&lt;/p&gt;

&lt;p&gt;Where does policy live in a low-code platform? In a validation expression on a field, in a condition on a form, in a branch in a workflow, in a line of a script, in a configuration row on a dashboard. Five places, five editors, five mental models, one business rule. There is no unit called "the rule" that you can define once and reference from all five. So when we sell reuse, we sell it at the layer that is easiest to package — the visual layer — and the layer that actually needs reuse stays copied and scattered.&lt;/p&gt;

&lt;p&gt;The result is a company that appears to have standardized, because every form looks the same, while the behavior underneath has drifted into eleven slightly different truths.&lt;/p&gt;

&lt;h2&gt;
  
  
  A component is not reusable. It is anchored.
&lt;/h2&gt;

&lt;p&gt;When a platform finally builds proper custom components, there is a detail that almost nobody reads until it bites them. A component built against the platform's runtime library cannot run outside the platform base — it is tethered by design. The runtime gives it context, permissions, data binding, the whole host environment. That is a reasonable engineering decision. It is also a scoping clause hiding inside the word reusable.&lt;/p&gt;

&lt;p&gt;Reusable turns out to mean reusable &lt;em&gt;here, under these conditions, against this version of the base&lt;/em&gt;. Move it to a different environment, a different tenant, or an external page, and the anchor becomes a wall. In one integration I watched a team spend three weeks discovering that a component they called "shared" could not leave the sandbox it was born in, because it depended on the platform's own context APIs. It was shared. It just could not travel.&lt;/p&gt;

&lt;p&gt;This is not a bug. It is the honest shape of reuse in a hosted platform, and it deserves to be said out loud before someone builds a strategy on top of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reuse without versioning is just coupling with better marketing
&lt;/h2&gt;

&lt;p&gt;Here is the part that low-code platforms have largely skipped, and it is the part every mature package ecosystem spent decades learning.&lt;/p&gt;

&lt;p&gt;When you reference a shared component, you now have a dependency. A dependency needs a version, a changelog, a compatibility range, an owner, and a way to say "this is deprecated, migrate by March." Public package managers learned this the hard way. Low-code platforms mostly skipped it, because there was no way to express it: a component was a row in a table, and editing the row changed it for everyone, instantly, silently.&lt;/p&gt;

&lt;p&gt;So the shared component in a low-code platform behaves like a dependency with no lockfile. Someone edits it to fix one team's problem. Every other consumer shifts underneath them without a release note, without a test, without a choice. The more successful your reuse program is, the more violent that drift becomes — the component with forty consumers is exactly the one nobody dares touch.&lt;/p&gt;

&lt;p&gt;Reuse, it turns out, is not the opposite of duplication. Done badly it is a more efficient way to spread the same change everywhere at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who owns the thing everyone copied?
&lt;/h2&gt;

&lt;p&gt;The deepest problem is not technical. It is that a shared asset needs an owner, and the moment you create a component "for everyone," you have created something that belongs to no one. It was built for a project, by a person, against a deadline. That project shipped. The person moved on. The component now has forty dependents and zero parents.&lt;/p&gt;

&lt;p&gt;Compare that with a library. A library is maintained because someone's job is to maintain it. Low-code organizations almost never staff that role, because the whole premise of reuse is that it removes work. It does remove work — the work of writing the same thing twice. It does not remove the work of owning it. It just moves it somewhere invisible, to a person who does not know they have it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;The feature that makes reuse effortless is the reason reuse fails.&lt;/p&gt;

&lt;p&gt;Low-code platforms became popular by deleting the friction from software construction — no build step, no packaging, no dependency manifest, no release train. Reuse rode along on that deletion for free, which felt like a gift. But friction is not always waste. In a software ecosystem, packaging, versioning, ownership, and deprecation are not bureaucracy. They are the mechanisms that let one thing be safely shared by many. Delete them and sharing stops being safe, and unsafe sharing is just a slower kind of copying.&lt;/p&gt;

&lt;p&gt;So the honest conclusion for anyone building or buying a low-code platform is uncomfortable: if you want real reuse, you have to make reuse harder. You have to require a reference instead of encouraging a copy. You have to make a shared asset carry an owner, a version, and an expiry. You have to accept a little friction at t=0 so that you do not inherit a decade of drift at t=n.&lt;/p&gt;

&lt;p&gt;Back in Dongguan, we eventually consolidated eleven inspection forms down to one. It took longer than the original copy ever did. The quality manager said something I now repeat in every architecture review: "Copying was easy. Owning all the copies was the hard part."&lt;/p&gt;

&lt;p&gt;She was right. The copy button is not free. It just sends the bill to a future team.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>architecture</category>
      <category>design</category>
      <category>enterprise</category>
    </item>
    <item>
      <title>Your Low-Code Platform Is a Credential Store Whether You Planned It or Not.</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Sat, 10 Oct 2026 02:32:06 +0000</pubDate>
      <link>https://dev.to/informat/your-low-code-platform-is-a-credential-store-whether-you-planned-it-or-not-5d6n</link>
      <guid>https://dev.to/informat/your-low-code-platform-is-a-credential-store-whether-you-planned-it-or-not-5d6n</guid>
      <description>&lt;p&gt;I was on a call with a customer's IT lead four months after their system went live. He was calm, which was the strange part. Then he said the sentence I have not been able to unhear since: "We found our ERP key in a data export."&lt;/p&gt;

&lt;p&gt;It had been sitting in a table row the whole time. Someone in procurement had filled in a field labelled "Access Token" during go-live testing. That field was in the export template. The export template was emailed to three people. One of them had a habit of dropping exports on a shared drive.&lt;/p&gt;

&lt;p&gt;Nothing was hacked. Nothing needed to be. The platform had done exactly what we built it to do: it treated a credential as data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Every secret we own has a home now
&lt;/h2&gt;

&lt;p&gt;Let me describe my own product honestly, because the pattern is not unique to us.&lt;/p&gt;

&lt;p&gt;At the team level we store a company secret — the key that guards the tenant's data. At the application level we store an app key per application. At the user level we issue API keys so external agents and MCP clients can call the platform on a user's behalf, with an optional expiry, and you can create as many as you like for different agents and different lifespans.&lt;/p&gt;

&lt;p&gt;Three scopes, one idea, and each of them was invented separately because each solved a problem at a different layer. Nobody sat down and designed "credential management." We designed an integration builder, then a customization capability, then an AI access path, and each one needed a key.&lt;/p&gt;

&lt;p&gt;That is how a low-code platform quietly becomes a credential store. Not by decision. By accumulation.&lt;/p&gt;

&lt;p&gt;The uncomfortable part is not that we store secrets. Every platform does. The uncomfortable part is that we store them as &lt;em&gt;fields&lt;/em&gt;, and fields inherit every property of the data model around them — the read permissions, the export path, the audit trail, the search index, the dashboard aggregation, the notification template, the AI agent's query scope. A password stored in a text field is not a password. It is a record.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four questions nobody writes down
&lt;/h2&gt;

&lt;p&gt;When a customer connects their ERP, nobody asks these questions out loud. Six months later, everybody does, usually during an incident.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who owns it?&lt;/strong&gt; Not who created it — who is accountable when it expires at 2 AM on a Sunday. A shared human account is not an owner. Every connector should have a service identity and a named person behind it. In practice, the key belongs to whoever pasted it in, and that person may have left the company.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where does it live?&lt;/strong&gt; If the answer is "in the configuration," then it also lives in every clone, every export, every backup, every screenshot attached to a support ticket, and every environment it was copied into. Our backup runs keep thirty days of platform configuration offsite, which is good disaster recovery and terrible secret hygiene. The backup is not the problem. The secret in the backup is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does it live?&lt;/strong&gt; We let people set an expiry on API keys, which is more than many platforms do. But an expiry you cannot enforce is a suggestion. If rotation means editing a field in five places — the connector config, the scheduled job that reads it, the script that calls the API, the test environment clone, and the documentation page — then rotation is a project, and nobody schedules projects for things that are currently working.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who can read it?&lt;/strong&gt; This is the question that breaks the mental model, because in a low-code platform the answer is not "security team." The answer is "everyone who can see the table." Anyone with read permission on that configuration record, anyone who can export, anyone whose dashboard is built on it, anyone who receives a workflow notification that includes it, and — increasingly — any AI agent that was granted data access and decides the table looks interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloning is our best feature and our worst credential habit
&lt;/h2&gt;

&lt;p&gt;Here is the thing that no integration tutorial tells you: our clone button is the most dangerous feature on the platform for credentials.&lt;/p&gt;

&lt;p&gt;Clone an application and you clone its connector configurations, including whatever key was embedded in them. Test environment now points at production. A developer runs a test and creates a real invoice, or worse, a test that sends a real notification to a real customer. When this happens, teams blame the developer. The developer did nothing wrong. The platform gave them a one-click path from a safe environment to a production credential.&lt;/p&gt;

&lt;p&gt;Then it compounds. The clone gets exported for a partner. The export gets version-controlled. The repository is private, until it is not — or until someone pastes the configuration into a chat message to ask a question. Every configuration artifact that leaves the platform carries whatever was inlined into it, and a credential inlined into configuration is a credential in the wild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the leak actually comes from
&lt;/h2&gt;

&lt;p&gt;Most credential incidents I have seen were not attacks. They were features.&lt;/p&gt;

&lt;p&gt;The "test connection" button that helpfully prints the outgoing request when it fails. The error page that includes the endpoint URL with the token in the query string. The audit history that records that a field changed — and stores both old and new values, forever, in a table anyone with audit access can search. The search index that crawls a configuration table because indexing everything is what indexes do. The attachment URL that carries its own token, so the link works for anyone who receives it. The screenshot in the ticket that the customer posted to a public support forum.&lt;/p&gt;

&lt;p&gt;Our own integration documentation is explicit about the intent: workflows should reference managed secrets rather than exposing values in configuration, logs, screenshots, or exports. That is the correct rule. It is also a rule that a drag-and-drop builder makes very easy to break, because the easiest way to make an integration work in ten minutes is to paste the key into the box.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "done" would look like, and why it never is
&lt;/h2&gt;

&lt;p&gt;If I were to design this properly — and I am now arguing we have to — a credential would not be a field. It would be an entity with a name, an owner, a scope, an environment, a rotation date, and a read log. Connectors would reference it by name. The value itself would never be returned to the interface after creation, because there is no legitimate reason for a builder to read a key it already configured.&lt;/p&gt;

&lt;p&gt;Rotation would be a scheduled job on the platform, since the platform already has a scheduler. Revocation would actually revoke: deleting a key in the interface must invalidate the cached copy inside a running process, not just the row in the database. And rotation would need a dual-key window, because the old key has to keep working until every consumer has the new one. That is not a feature you can bolt on later. It is a design decision about how the platform talks to the outside world.&lt;/p&gt;

&lt;p&gt;The hard truth is that none of this is new. It is standard service-identity hygiene that any backend team already knows. The only thing low-code adds is scale and pace. We made credential creation point-and-click, and then measured our success by how fast a business analyst could connect a system without talking to IT.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;I used to be proud of that ten-minute integration. A procurement analyst built a live connection to the ERP without a single ticket, and I put it in a slide.&lt;/p&gt;

&lt;p&gt;What she also built, in those ten minutes, was an enterprise-wide credential with no owner, no expiry, no scope, no inventory, and no way for the security team to enumerate it. The platform made secret management so frictionless that it stopped looking like security work at all.&lt;/p&gt;

&lt;p&gt;That is the uncomfortable conclusion. Our best-loved feature was a credential factory, and we shipped it without a registry.&lt;/p&gt;

&lt;p&gt;The integration was never the hard part.&lt;/p&gt;

&lt;p&gt;Keeping the key is.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>security</category>
      <category>api</category>
      <category>architecture</category>
    </item>
    <item>
      <title>You Backed Up the Database. You Did Not Back Up the Application.</title>
      <dc:creator>informat</dc:creator>
      <pubDate>Fri, 09 Oct 2026 02:49:34 +0000</pubDate>
      <link>https://dev.to/informat/you-backed-up-the-database-you-did-not-back-up-the-application-4be2</link>
      <guid>https://dev.to/informat/you-backed-up-the-database-you-did-not-back-up-the-application-4be2</guid>
      <description>&lt;p&gt;The IT manager at a plant in Dongguan called me on a Wednesday afternoon. Six hours earlier one of her admins had run a bulk update against an inspection table with the wrong filter attached, and sixty thousand rows now carried a blank where a measurement should have been.&lt;/p&gt;

&lt;p&gt;"I do not need you to restore the server," she said. "I need you to restore that one table from last night. Everything else — the other thirty applications, the production dashboard, the approval queues — has to keep running. Can you do that?"&lt;/p&gt;

&lt;p&gt;I told her I would get back to her. It took four days, and most of the work was manual.&lt;/p&gt;

&lt;p&gt;That is the phone call I think about whenever someone asks whether our platform has backups. We did. There was a cron job that ran at two every morning and wrote two database dumps to a folder, exactly as our own documentation describes. What we did not have was a way to give her the thing she actually asked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  A backup is a claim about a system
&lt;/h2&gt;

&lt;p&gt;Here is the uncomfortable arithmetic. When you dump the platform database, you have captured part of the state. Not the whole of it.&lt;/p&gt;

&lt;p&gt;Our platform keeps identity and permissions in one library and business data in another — two dumps, two files, two things that have to stay consistent with each other. In larger deployments the business library is already numbered, because it shards. The uploaded files, the scanned certificates, the photos people attach to records, live somewhere else entirely: object storage in a bucket, plus a local file directory that some installations still rely on. And the search index is derived, so we tell ourselves it does not need a backup — which is true right up until you restore and somebody notices the search box has gone quiet.&lt;/p&gt;

&lt;p&gt;So "we back up the database" is a sentence that describes roughly half of the stateful parts of the system. It happens to be the half that is easiest to describe, which is why it is the half everyone stops at.&lt;/p&gt;

&lt;p&gt;The missing pieces are not exotic. They are just boring enough that nobody owns them. The attachments are the most concrete example: a row restored without its file is a record pointing at an empty preview. The database says the certificate exists. The storage layer says it does not. Both are telling the truth about their own copy, and the user sees a broken link.&lt;/p&gt;

&lt;h2&gt;
  
  
  In low-code, the schema is data too
&lt;/h2&gt;

&lt;p&gt;In a hand-written application, schema and data are different artifacts. They change at different rates, they are reviewed differently, and you can reason about restoring one without the other. That separation is the thing low-code quietly removes.&lt;/p&gt;

&lt;p&gt;In our platform, adding a field is a metadata change. It also causes a physical column to appear. A table's physical name is a compound of two system-generated identifiers — stable for the table's whole life, and completely meaningless to a human reading the database. A "table" in the product is not a table in the database; it is an entry in a definition, plus a physical structure, plus indexes.&lt;/p&gt;

&lt;p&gt;The consequence is that backing up the application and backing up its data are not two projects. They are one project, and most teams only discover that when they try to separate them. You cannot meaningfully restore yesterday's rows into today's definition without deciding which definition wins — and if you restore the definition, you have also just rolled back every field, rule and form change made since, for every user, not just the one who made the mistake.&lt;/p&gt;

&lt;p&gt;This is why "restore the backup" is such a dangerous sentence to say to a non-engineer. It sounds like undo. It is closer to a time machine that moves everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The request is never "restore the instance"
&lt;/h2&gt;

&lt;p&gt;Our documented recovery procedure is honest and it is blunt: before restoring, ensure the database has no active connections. That is a technical way of writing "stop the platform." Afterwards you restart the services, and then you fix what the restore dragged along with it — the licence is bound to the machine, so it needs re-authorising; the home address, login URL, mobile login URL, resource address and API address all get reset to whatever the source environment used; the document preview service points at the old host.&lt;/p&gt;

&lt;p&gt;A restore, in other words, carries a whole environment with it. It is not self-contained, and it is not scoped. It is an all-or-nothing operation on an instance that is currently hosting forty applications for a dozen departments, at least one of which is probably running a shift handover.&lt;/p&gt;

&lt;p&gt;That was the real gap. The customer's request was never "restore the instance." It was "restore this table, this time window, without touching anything else." Scope is the requirement, and scope is the part nobody builds, because building it means having an answer to a question we had never asked: what is the smallest unit of state you can name, isolate and put back?&lt;/p&gt;

&lt;p&gt;Row-level operations are possible — you can extract a single physical table and reinsert it. But the trap is downstream. Every table carries its own sequence for record identifiers, and a numbered field carries a second one. Restore the rows and forget the sequence and the platform will happily hand out an identifier that already exists. Nothing fails that day. Something fails three weeks later, when a duplicate record number appears in a report and nobody can explain where it came from.&lt;/p&gt;

&lt;p&gt;A delayed, silent, data-level failure is the worst kind of bug a platform can ship. And it is the natural result of treating a restore as a file copy instead of a reconciliation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retention, where backup and compliance disagree
&lt;/h2&gt;

&lt;p&gt;There is a second contradiction, and it is harder to design around than the first.&lt;/p&gt;

&lt;p&gt;Backup windows are short because storage is not free. Our own reference script for object storage keeps thirty days of copies and deletes the rest. Meanwhile the audit log is supposed to be long-lived evidence, the kind of thing an auditor asks for by year. And then a customer asks us to erase a person's data, completely, now, because a regulation says so.&lt;/p&gt;

&lt;p&gt;You can promise durable point-in-time recovery. You can promise complete erasure. You cannot honestly promise both from the same copy of the data, because a dump is by definition a record you agreed to keep. Every platform that says yes to both is choosing which of the two sentences it is lying about, usually without noticing that it made a choice.&lt;/p&gt;

&lt;p&gt;We stopped pretending. The backup exists to restore service, it has a documented lifetime, and erasure has a documented process that includes ageing out of the backup window. That is a less impressive answer than "we delete everything instantly." It is also a true one, and enterprise buyers can tell the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable conclusion
&lt;/h2&gt;

&lt;p&gt;We had treated backup as an operations concern — a cron job, two dumps, a folder, an scp to another machine. It is not. It is a product surface, and it is one of the few surfaces a customer only ever sees on their worst day.&lt;/p&gt;

&lt;p&gt;Low-code is a machine for creating state with a drag. A business analyst can conjure a table, a rule, and an automation before lunch. If the platform cannot also name, scope, and restore that state, then it has handed the customer a way to create work that nobody can undo — and it will only find out when the phone rings on a Wednesday.&lt;/p&gt;

&lt;p&gt;Backups are cheap.&lt;/p&gt;

&lt;p&gt;Restores are the product.&lt;/p&gt;

</description>
      <category>lowcode</category>
      <category>database</category>
      <category>devops</category>
      <category>architecture</category>
    </item>
  </channel>
</rss>
