DEV Community

informat
informat

Posted on

Two People Edited the Same Record. Your Platform Called It a Successful Save.

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.

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.

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?"

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.

The page is a photograph

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.

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.

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.

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.

We already built the lock. We gave it to the scripts.

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.

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.

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.

We already compute the diff. We use it for blame.

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.

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.

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.

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.

The token that could have been a version

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.

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.

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.

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.

The second copy nobody thinks about

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.

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.

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.

Why we did not fix it

Because conflict resolution asks a question, and a question becomes a support ticket.

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.

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.

So we optimized for the metric we could see. Not a bug in our engineering — a bug in what we chose to measure.

The uncomfortable conclusion

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.

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.

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.

And we made the witness optional.

The write succeeded. The edit did not survive. We logged nothing, because logging is a setting.

We have been calling that a successful save for six years.

Top comments (0)