# Shibin Das: all posts and studies > I design and build tools that make people's lives simpler. --- # FlowDrop Workflow Specification 1.0: Why a Workflow Engine Needs a Spec > FlowDrop now has a published, implementation-independent workflow specification: 386 rules with permanent identifiers, conformance fixtures, and a clear answer to absent versus null in JSON. Why we pulled the rules out of the Drupal module, and what a written spec fixes that a test suite cannot. By Shibin Das · FlowDrop · Published 25 Oct 2026. Source: https://www.d34dman.com/posts/flowdrop/flowdrop-workflow-specification-1-0/ Tags: flowdrop, specification, workflows, json, json-schema, api-design, orchestration, drupal, rust, open-source **Scope note:** This post is about the [FlowDrop Workflow Specification](https://flowdrop.io/spec/), which reached version 1.0 this week. It covers why it exists, what it changed, and the handful of design decisions in it that I think are worth stealing for your own API, whether or not you ever touch FlowDrop. It does not cover the editor, the AI nodes or the engine internals. --- ### TL;DR - **What it is:** 386 rules saying how a FlowDrop workflow is written, stored, validated and executed, stated without reference to any one implementation. 217 core, 167 extended, 2 optional. - **Why:** the rules used to live inside FlowDrop for Drupal, extracted from its code. They described one PHP implementation very well and could not be used to build a second one. - **What it fixes:** a workflow should mean the same thing on every engine that runs it, and when two engines disagree, there should be one place that says which one is wrong. - **What you can take from it even if you never use FlowDrop:** a strict definition of absent, `null` and empty in JSON, open lists with a name register instead of `X-` prefixes, floors instead of ceilings, and error codes instead of error messages. - **Licence:** spec text CC BY 4.0, code and test fixtures MIT. Source on [GitHub](https://github.com/flowdrop-io/flowdrop-spec). ## Where the rules used to live [FlowDrop](https://www.drupal.org/project/flowdrop) ([why I built it](https://www.d34dman.com/posts/flowdrop/configurable-workflows-future-flowdrop/)) started as a Drupal module with a visual editor in front of it. Early on we made one decision that paid off more than any other: the editor talks to its backend over an API and knows nothing about Drupal. That is why a FlowDrop workflow can run on a backend that is not Drupal at all. For a long time, though, the rules about *what a workflow means* lived in the Drupal module. There was a specification registry in its docs, every rule had an identifier, and the test suite was bound to those identifiers, with CI failing if a rule lost its test. As a regression suite it was excellent. Over 370 rules, all covered. Then we tried to read it as a specification, and asked one question of every rule: could a team build this in TypeScript, or Rust, from the text alone, without reading the PHP? A lot of rules failed. Not because they were badly written, but because they were extracted from code, and code records its accidents as faithfully as its intentions. A few examples of what the registry was guarding with tests: - **A workflow named `0` counted as having no name**, because PHP's `empty('0')` is true. A JavaScript or Rust implementation would never produce that behaviour by accident, and should not be asked to produce it on purpose. - **Case-insensitive regex matching worked by lowercasing the pattern.** That turns `\D` (not a digit) into `\d` (a digit), so a condition written to match non-digits started matching digits. - **Clients told one refusal from another by matching the error message.** Rewording a sentence was a breaking change. - **A `null` on an input meant different things depending on which code path built the inputs**, because one used `array_key_exists()` and another used `isset()`. Each of these had a test, and each test was defending the bug. The registry described today's system brilliantly. You could not build tomorrow's system from it. ## Why that matters now FlowDrop is no longer one implementation. The spec's own drafting guide puts it plainly: *PHP, JavaScript and Rust today, Python, Go or any other tomorrow.* There is the Drupal module, the JavaScript editor that has to agree with whichever backend it talks to, and [flowdrop-rs](https://github.com/flowdrop-io/flowdrop-rs), a Rust backend. The moment a workflow is saved by one implementation and read by another, every rule that quietly depended on a host language becomes a bug that only shows up in production, on somebody else's server. And the first question anyone asks when two engines disagree is: *which one is right?* A test suite cannot answer that. It can only tell you which implementation changed. There is a second reader I had in mind the whole time: the agent debugging the workflow. More and more of the "is this a bug?" questions about FlowDrop are asked by a model reading logs at two in the morning. A spec written so that one rule decides one observed behaviour is as useful to that model as it is to a person. More on that below. ## What 1.0 actually is | | | |---|---| | Rules in force | 386 (217 core, 167 extended, 2 optional) | | Rule families | 30, from `GR-STORE` (storing workflows) to `RT-GATE` (human approval gates) | | Rulings | 43, each recording what was decided and why | | Withdrawn rules | 21, kept on record, numbers never reused | | Deferred rules | 22 that stayed in the Drupal module because they were about Drupal | | Conformance fixtures | 53, in the first suite | Every rule declares three things: - **Profile:** who it binds. `runtime` (executes workflows), `storage-api` (stores and serves them) or `editor-client` (authors them). An editor does not have to care about retry semantics; an engine does not have to care about what an editor records. - **Level:** `core` is what you meet before calling yourself a FlowDrop implementation. `extended` is commonly expected. `optional` is genuinely optional. - **Posture:** almost every rule is a `normative-target`: it says what a conforming implementation must do, even if no implementation does it yet. That last word matters. The spec is a **target**, not a description. If an implementation disagrees with a rule, the implementation has a defect, unless the rule is wrong, and then that is a bug in the spec, reported against the spec repository. It also deliberately carries **no implementation status**. Nothing in it says which implementation passes which rule. Each implementation publishes that itself. ### Rule identifiers are forever `STORE-2` means one thing, forever. Identifiers are cited from tests, issues and other people's documents, so they are never renumbered and never reused. A withdrawn rule stays on record as withdrawn. There is one subtler case. When a rule was moved out of the Drupal registry, its number was already in use there for a rule the spec declined. Issuing that number for something else would make one identifier mean two things in two documents that cite each other. So those numbers are **reserved**: recorded in an append-only `REGISTRY.lock`, and CI refuses any rule file that claims one. ## Design decisions worth stealing These are the parts I would copy into any JSON API spec, FlowDrop or not. ### 1. Absent, null and empty are four different states This was the single largest source of disagreement between implementations, and the ruling that settled it ([OPEN-26](https://flowdrop.io/spec/rulings/open-26)) touched more rules than any other. The spec now defines it once, for every payload: | State | JSON | Meaning | |---|---|---| | Not provided | the member is absent | A fallback applies: a lower priority, a default. | | Provided, no value | `null` | A choice. It replaces any fallback. In a partial update it clears the stored value. | | Provided, empty | `""`, `[]`, `{}`, `false`, `0` | A value, like any other. | | Provided, with content | anything else | A value. | No rule reads an empty value as absent. No implementation may drop a `null` or fill in an absent member on its way through. The reasoning is short: a value that one implementation reads as "absent" changes meaning when it is serialised and read back by another. It also agrees with JSON Schema with no exceptions, so an off-the-shelf validator in any language gives the spec's verdict. Here is one of the fixtures that checks it: ```json { "title": "A null on the wire beats the author's saved value", "rules": ["CFG-4", "CFG-7", "CFG-14"], "operation": "resolve-parameters", "given": { "parameters": { "x": { "schema": { "type": ["string", "null"] }, "connectable": true, "exposed": true, "configurable": true } }, "config": { "x": "saved" }, "inputs": { "x": null } }, "expect": { "resolved": { "x": null } } } ``` A wired-in `null` wins over the author's saved `"saved"`, because sending `null` was a choice. Had the input been absent, the saved value would apply. ### 2. Lists are open unless a rule says they are closed Many rules used to list the members of an object with the word "exactly". Read strictly, every extra member was a breach, including ones clients depend on. Read loosely, nothing stopped two implementations giving the same added name two meanings. The spec now says lists are **open by default**. An implementation may add members; a consumer must ignore what it does not recognise. A rule that needs a closed list says *closed* and gives the reason. Added names go in an [extensions register](https://github.com/flowdrop-io/flowdrop-spec/blob/main/extensions.md), **without a required prefix**, because a prefix turns into a rename the day the name is standardised. HTTP learned that with `X-` headers ([RFC 6648](https://www.rfc-editor.org/rfc/rfc6648)). The condition node's operators are an example of a list that *is* closed, with the reason written into the rule ([LANG-28](https://flowdrop.io/spec/rules/gr-lang/lang-28)): a condition is stored in the workflow, so an operator only one engine understands would make that workflow fail to save on every other. ### 3. Floors, not ceilings "A name may be at most 255 characters" freezes one system's database column into every other system. The spec writes it as a floor instead: an implementation **must accept at least** 255, and may accept more ([STORE-2](https://flowdrop.io/spec/rules/gr-store/store-2)). A caller that stays within the floor works everywhere. And "characters" is pinned down: text length counts **Unicode code points**. Not bytes, which would make a limit depend on the alphabet you write in. Not grapheme clusters, which are closer to what a person calls a character but much harder for two implementations to agree on. ### 4. Equality is defined, not inherited When a rule compares two values, it compares them the way JSON Schema's `const` does. `1` equals `1.0`. `"1"` does not equal `1`. `true` does not equal `1`. Object members compare regardless of order. Your language's `==` gets some of these right and some wrong, so the spec says to compare by its definition, not by the operator. ### 5. Error codes, not error messages Every refusal carries a stable, machine-readable `error_code` next to the human-readable message ([API-8](https://flowdrop.io/spec/rules/gr-api/api-8)). A client must not classify a refusal by matching its text. A published code never changes meaning and is never reused. It is a boring rule. It is also the one that lets you fix a typo in an error message without a major release. ### 6. The spec carries no history The spec states what is required now. It does not say what used to be required, and it has no legacy appendix telling implementations to tolerate old forms ([OPEN-36](https://flowdrop.io/spec/rulings/open-36)). Why something changed lives in the ruling that changed it. How an implementation migrates its own stored workflows lives in that implementation's release notes. A new implementation should not have to carry somebody else's migration history. ## Two tests every rule has to pass The [drafting guide](https://github.com/flowdrop-io/flowdrop-spec/blob/main/drafting.md) has 21 checks, and a review cites them by number. Two of them carry most of the weight: - **The outsider test:** could someone implement this rule correctly from its text, the conventions and the glossary alone, in a language and framework unlike any existing implementation's? - **The debugger test:** could someone debugging a workflow, *a person or an agent*, decide from this rule alone whether an observed behaviour is correct, and so whether to file a bug and against whom? If two readings of a rule are defensible, the rule is ambiguous, and that is a defect in the rule. ## Written to be read by machines too The spec gets quoted into issues, code reviews and model context at least as often as it gets read in a browser, so it is published for both: - Every rule page has a **markdown twin** at the same URL with `.md` appended, for example [`/spec/rules/gr-store/store-2.md`](https://flowdrop.io/spec/rules/gr-store/store-2.md). Each one carries its identifier and a link back to its source, so a quotation stays traceable. - [`llms.txt`](https://flowdrop.io/spec/llms.txt) is an index, one line per rule. [`llms-full.txt`](https://flowdrop.io/spec/llms-full.txt) is the whole spec as one document. - [`rules.json`](https://flowdrop.io/spec/rules.json) is the corpus as data, including the identifiers the spec has *declined*, so a tool resolving a number can tell "not ours" from "unknown". - Crawlers are allowed everywhere, deliberately. A rule nobody can find is a rule nobody conforms to. (Whether anything actually reads those files is a separate question, and [your analytics probably cannot tell you](https://www.d34dman.com/posts/engineering/llms-txt-analytics-blind-spot/).) If you are pointing a coding agent at a FlowDrop implementation, hand it `llms-full.txt` and ask it to cite rules by identifier. ## Conformance fixtures Rules are for reading. Fixtures are for running. Each fixture is a JSON file naming the rules that decide it, an operation, an input and the expected outcome, like the one above. An implementation writes a small adapter that maps `given` onto its own API and its result back onto `expect`, and can copy the fixtures straight into its test suite, since they are MIT licensed. The comparison is strict on purpose. A member present in `expect` must be present with an equal value. A member absent from `expect` must be absent from the outcome. There is no "don't care". Without that, absent versus `null` would be untestable, and that was the whole point. ## How it is governed, honestly The spec is maintained by Factorial, who also build two of its implementations. It is a **vendor-led specification with a public target**, not the output of a neutral standards body, and the [governance document](https://github.com/flowdrop-io/flowdrop-spec/blob/main/GOVERNANCE.md) says so in its first paragraph. Pretending to a neutrality we do not have would be worse for everyone relying on it. What constrains us instead is the record. Any change to what an implementation must do needs a **ruling**: a short, permanent statement of the question, the decision and the reasoning, carried in the spec and linked from every rule it affects. A rule that changes without a ruling is a process defect, and pointing that out is a legitimate issue to file. Contributions come in two kinds, and the pull request has to say which: - **Editorial:** the rule requires exactly what it did before and now says so more clearly. Usually merged. - **Normative:** an implementation that conformed yesterday might fail today, or the reverse. Needs a ruling, so open an issue first. The test for which one you have: could an existing implementation pass before your change and fail after it? If you are not sure, it is normative. The most useful thing you can file is "this rule is wrong". ## What it is not - It is **not a certification**. It grants no mark and no status. - It does **not track implementations**. No scoreboard, no ranking. - It is **not finished**. The first fixture suite covers absent, `null` and empty through parameter resolution, inputs and outputs. The other 29 families have rules but not yet fixtures. ## Where to start - **Building an implementation?** Read the [conventions](https://flowdrop.io/spec/conventions) first, then filter the [rules](https://flowdrop.io/spec/rules) by your profile and `core`. - **Authoring workflows?** The [glossary](https://flowdrop.io/spec/glossary) and the `GR-` families are the part that decides what your workflow means. - **Designing your own JSON API?** Steal the [Payloads](https://flowdrop.io/spec/conventions#payloads) and [Extensibility](https://flowdrop.io/spec/conventions#extensibility) sections. CC BY 4.0 says you can, as long as you say where you got them. - **Found a rule that is wrong?** [Open an issue](https://github.com/flowdrop-io/flowdrop-spec/issues). The short version of why we did this: a test suite tells you what your code does. A specification tells you what it should do. For a long time FlowDrop only had the first one. Now it has both, and the second one does not care which language you write in. --- # The Upgrade Path Is a Product Decision > Atelier is built for people who have never heard of Drupal and will never open a terminal. That single fact turns 'it upgrades itself' into a veto over the dependency list — and a refactor where I had to say no to modules we wanted, delete code we were proud of, and start reading other people's roadmaps. By Shibin Das · Engineering · Published 20 Aug 2026. Source: https://www.d34dman.com/posts/engineering/upgrade-path-is-a-product-decision/ Tags: upgrade-path, dependencies, drupal, atelier, flowdrop, ai, maintenance, engineering, open-source [Atelier CMS](https://www.d34dman.com/posts/musing/atelier-is-live/) makes one promise that shapes almost every other decision in it: > Any version upgrades to the latest. Fully automated. No hiccups. Not "supported upgrade path." Not "see UPGRADING.md." The operator pulls a new image, the appliance converges itself, and their site keeps working — whether they last upgraded last week or six releases ago. That sentence reads like an ops feature. It isn't. It's a **veto**, and it sits on the part of the process where the fun happens: choosing what goes in. Because the thing about an automated upgrade is that it has to work on a machine I have never seen, whose database I cannot inspect, running a version I shipped months ago and have half-forgotten. Every dependency in the product is a dependency in that unattended migration. Every module we enable is a module some future release will have to uninstall *for* someone, remotely, unsupervised, on the first try. We just finished a refactor that was mostly about honouring that. Here is what it actually cost. But first, why the rule exists at all — because it isn't a taste thing. --- ## Who Is On The Other End Almost every upgrade convention in the Drupal world quietly assumes a competent hand at the other end. `composer update`, then `drush updatedb`, then read the deprecation notices, then fix the two contrib modules that broke, then export config. It's a fine assumption when the other end is an agency with a staging environment and a retainer. Atelier is not built for that end. It's built for the person who wants a website, has no agency, has never heard of Drupal, and does not care that there's a Drupal in there at all. They aren't going to open a terminal. They shouldn't have to know what a terminal is. Work forward from that and the whole thing follows: **They cannot run a command.** So there is no upgrade *procedure* — there's a new version, and it applies itself. Anything that would have been a step in a document has to become code that runs unattended. **They cannot diagnose a failure.** This is the part people underestimate. It isn't that debugging is *inconvenient* for them — it's that the failure is **unreadable**. A stack trace, a config-import conflict, a module stuck half-uninstalled: to a non-technical operator these are all the same event, and that event is *"my website is broken and I don't know why."* There is no partial recovery, no "just run this one command." There's a support request, or there's abandonment. **They cannot judge risk in advance.** An agency reads a release note and decides whether to take it this sprint. Our operator sees "update available." So we don't get to offload the decision. If it's offered, it's safe — that has to be true, because it's the only thing they can act on. **Nobody is holding a backup they know how to use.** The appliance takes snapshots, but "restore from a snapshot" is *our* job to make one action, not a runbook we hand over. Put together, the standard escape hatch is gone. Most software's upgrade story is really *"upgrade, and if it goes wrong, someone technical will sort it out."* Strip out that clause and the remaining sentence has to be true on its own. So the bar isn't "as good as Drupal upgrades usually are." The bar is **a phone app**. Nobody reads a changelog before their maps app updates, nobody keeps a rollback plan, and nobody has ever had to uninstall a leftover dependency by hand afterwards. That's not a low bar because it's easy — it's a low bar because ordinary people have been trained by every other piece of software they own to expect it, and a CMS that fails it feels broken even when it's behaving exactly as documented. That is where the veto comes from. It isn't discipline for its own sake. It's that the *only* alternative to getting this right is asking someone who cannot possibly do it to do it anyway. --- ## Removing a Module Takes Two Releases Start with the smallest lesson, because it's the one that generalises. We had six provider modules to delete. Dead surface — nothing called them any more. So the cleanup did the obvious thing: dropped them from the enabled-modules config *and* removed the packages, in one release. That works perfectly on my machine. It breaks every site that already exists. Drupal needs a module's **code on disk** to uninstall it. Ship both halves at once and the code is gone before the uninstall runs, so the module stays enabled in the database with nothing left to remove it. The site isn't broken loudly. It's broken in a way that only shows up two releases later, on somebody else's install, as an unremovable ghost. So the rule we earned: ```text Release N drop it from core.extension + the install recipe KEEP the composer requirement (the code must still be there) Release N+1 composer remove ``` One deletion, two releases, in that order, always. It's written into the appliance README rather than a commit message, because the next person tidying `composer.json` will otherwise reintroduce the package and never know why it was pinned. The interesting part isn't the rule. It's the shape of the mistake: **the correct code, in the wrong number of releases.** You cannot catch that class of bug by reviewing a diff. You catch it by asking, every time, "what does this do to a site that skipped this version?" --- ## When Your Patch Queue Is Telling You Something The bigger no was leaving `drupal/ai` — the shared AI abstraction layer — and building our inference layer directly on `symfony/ai`. That looks like a not-invented-here decision from the outside. It wasn't. The argument that actually decided it was our own patch directory. We were carrying four Composer patches. Three were against the AI stack. And when I read them properly, the two that mattered were not *bugs*: - One fixed a tool payload emitting `"parameters": null` for a no-argument tool. OpenAI tolerates it. Put a proxy in front of Anthropic and the whole request dies. The payload was built in a base class every OpenAI-shaped provider inherits — so it was a **conformance failure inside the conformance layer**. - The other, seven hunks of it, existed because one provider *executed* tool calls internally instead of returning them. That's not a broken function. That's a different reading of what a chat provider is for — and fatal to us, because our workflow engine routes tools by design. Underneath those, our own onboarding code had quietly grown nine hardcoded per-provider maps and five documented behavioural divergences between providers that were supposed to be interchangeable. Roughly fourteen workarounds. None of them individually unreasonable. Here is the thing I'd have missed a year ago: **that count is not a code-quality signal, it's a structural one.** The layer couldn't enforce conformance because it didn't own the plugins it abstracted over — different companies, different repos, no authority to say no. A layer that can't reject a non-conforming implementation isn't an abstraction; it's a naming convention. And every workaround it needs is a silent-breakage candidate on an unattended upgrade. The alternative wasn't obviously safer, and I want to be honest about why it won: `symfony/ai`'s bridges live in **one repository, one CI run, one shared contract layer**. Co-location makes conformance *enforceable* — not guaranteed. We still verify every bridge we depend on. But "enforceable" is the difference between a dependency I can reason about and one I can only hope about. Two things made it a decision rather than a gamble. First, staying wasn't free either: the next major of the layer we were leaving narrowed its core requirement, restructured around five new plugin managers, and dropped seven submodules. So the real comparison was **migration against migration**, not migration against standing still. Second, we proved the migration on a live install before committing to it: a real tool-aware turn, resolved through our own reasoner, using the **credential already stored on that site** — no re-onboarding, because "the operator must re-enter their API keys" is exactly the hiccup the promise forbids. The migration deleted 84 lines whose comments were *entirely* about working around invalid JSON Schema being emitted upstream. Both patches became unnecessary. Six unit tests now lock that behaviour down — not as tests of a feature, but as regressions for the reasons the migration happened. --- ## Deleting Code You Were Right to Write The second half of the refactor was the one that stung. Our workflow engine is [FlowDrop](https://www.drupal.org/project/flowdrop), a contrib project — and I should say plainly that I maintain it, the same as I maintain Atelier. So this is not a story about waiting on a stranger's roadmap. We needed behaviour FlowDrop didn't have, so we ran a fork inside Atelier: our own node types, our own patches, our own plugin overrides. It worked. It was good code. Some of it I still like. A fork is also a promise you make *to yourself* to keep re-applying your cleverness to every future upstream release, forever, on behalf of people who will never know it exists. Under an automated-upgrade promise, a fork is a debt with a floating rate. Owning both ends doesn't cancel that debt; it just changes who you have to argue with. The fork was still a fork — patches in Atelier's repo, re-applied against every FlowDrop release. What changes is the failure mode. It isn't an unresponsive maintainer. It's the opposite: quietly landing product-shaped hacks in a general-purpose platform because you have commit access and a deadline, and discovering two releases later that FlowDrop's other users are carrying the shape of Atelier's problems. And the honest version was affordable for a reason I can't turn into general advice, so I'll just name it: **Factorial sponsors FlowDrop's development**, and that sponsorship paid for roughly a month of work on FlowDrop itself, for the Atelier collaboration on the AI Engine. Upstreaming is cheaper over the life of a product and more expensive this month. Somebody has to fund that gap, and most of the time nobody does — which is exactly why so many forks outlive the reason they were created. So instead of maintaining the fork, the asks went upstream — filed and reviewed as things the platform would want, not as things Atelier needed, which is a distinction you have to enforce on yourself when you're standing on both ends of it. Over a handful of alphas on the 2.3 line, they landed. And each time one landed, we deleted Atelier's version: - a node type we'd written for declining-and-synthesising in an agent loop → retired for the upstream node with identical ports - a whole class that patched a node type's config on-site because upstream omitted a setting → **deleted**, along with the update hook keeping it alive - our confirmation-policy stance → replaced by FlowDrop's own pass over its side-effecting node types That last one is the good example of *not* saying yes to everything, in both directions. FlowDrop decides which node types need a human confirmation by splitting on whether an effect leaves the site — the right call for a general workflow engine. Atelier adopts the split with exactly one exception: a node that saves content is re-pinned to **ask**, because the axis that predicts regret isn't whether the effect is external — it's whether an operator can undo it *without having noticed it happened*. A node that can publish gets a prompt. That exception stayed in the product rather than becoming the platform default, and it's one documented deviation instead of a fork. That's the shape the two hats are supposed to have. The refactor ended with the fork at zero and about 3,100 lines deleted. Nothing about the product got more capable that week. Every future upgrade got cheaper. Which is the trade I keep having to re-explain, including to myself: **the fork was the fast path, and the upstream work was the cheap one** — even when the upstream is yours. They are not the same axis, and the promise only cares about the second. > **Note:** > > **If your company is carrying a fork of an open source project, upstream it.** Not > for the ethics of it — for the invoice. A fork's real cost was never the patch. It's > that you re-pay for the patch on every upstream release, forever, and the bill grows > with the distance between your tree and theirs. Nobody budgets for it, because it > never arrives as a line item; it arrives as an upgrade that slipped a sprint, or an > engineer who is the only person who understands why your build has a `patches` > directory. > > Upstreamed code is maintained by everyone who touches the project after you. Their CI > runs it, their refactors carry it, their users find its bugs. You stop paying rebase > tax, your version bumps get boring, and — the part that surprised me — the review you > have to survive to get it merged tends to make the feature *more general* than the > version you'd have kept to yourself. > > The catch is only ever timing: upstreaming costs more this month and less every month > after. That makes it a funding problem, not an engineering one. If your fork exists > because upstreaming was never anybody's paid work, that's the thing to fix — sponsor > the maintainer, or buy your own engineers the weeks to do it properly. It is cheaper > than the fork. It is just never sooner. --- ## The Failure You Cannot Review One more, because it's the best argument I have for why this is a product concern and not a tidiness concern. Two decisions, each individually correct: 1. Our image stamps **every file at a fixed timestamp**, so builds are byte-stable and an appliance doesn't re-download ~75 MB of layers it already has. 2. Drupal 11.4 moved routing and library YAML parsing into a cache bin that is deliberately kept **out** of the cache-clear registry, because it's designed to survive one — it invalidates on file modification time instead. Read those together. After an upgrade, the new release's YAML is byte-different and timestamp-**identical**. Core's only invalidation signal says "unchanged." The site serves the previous release's parse forever, and **no cache clear can fix it.** Version 0.8.2 shipped in that state. A route existed on disk and not in the router, and a module linked to it on every console page — so an admin got an exception logged on every single page load of the backend. Found on live instances. Fixed by truncating one cache table. The fix, in the upgrade routine rather than the build: ```sh # before updatedb, so migrations + config import + router rebuild # all read the YAML we actually shipped. # advisory, never fatal: the bin is created lazily. truncate_file_parsing_cache_if_present ``` We deliberately didn't fix it by unfreezing the timestamps. That satisfies core's assumption honestly, but it pays layer churn on **every** release to fix a **once-per-upgrade** problem — and it makes correctness depend on a build detail nobody re-derives when they next touch the Dockerfile. The two halves now name each other in comments: the Dockerfile says *don't drop the truncate while these timestamps are frozen*, the upgrade script says *why the timestamps make it necessary*. And the real fix is neither of those — it's a test that takes a **real released image, upgrades it, and asserts every route our modules declare still resolves.** Guard the invariant, not the mechanism. The next cache that pins a stale parse fails the same test, and nobody has to be clever twice. Notice what this bug was *not*. Not a bad dependency. Not a careless commit. Two good decisions in different repos, colliding in a place no code review looks — an upgrade from a version that no longer exists in the working tree. --- ## So How Do You Actually Pick a Dependency? The refactor changed my checklist. It used to be roughly: does it work, is it maintained, is the code decent. Now, before anything enters the product, I want to know: **Can this thing's contract be enforced?** Not "is it well designed" — *who has the authority to reject a non-conforming implementation?* If the answer is nobody, expect to own the difference. Count the workarounds you already have. That number is the running cost, and it only goes up. **Can I see the roadmap?** Not a marketing roadmap. Issue queues, milestones, a maintainer who says out loud "2.0 will drop these submodules." A dependency with a visible future can be *planned around*; one without it can only be reacted to, and reacting is how you end up shipping a breaking change to somebody's unattended install. **How responsive are the maintainers — to me, specifically?** This is the metric I most underrated, and FlowDrop is the easy version of it rather than evidence for it: I maintain FlowDrop, so of course the asks landed. The real test was the AI layer, where I had no standing at all. Can you file a well-argued, platform-shaped ask and have it land in an alpha? That is the difference between deleting your fork and feeding it forever, and it's worth *testing* early with a small ask, before you're structurally committed. If you do own both sides, the question inverts: nobody else is there to reject the ask that's good for your product and bad for the platform. You have to be that person, on purpose, against your own deadline. **And what does saying no cost today?** Sometimes a lot. There are features we want, that our users would use next week, that we are not shipping because the module that provides them has a known incompatibility with something in our path. That's not caution, it's arithmetic: a feature is worth one release, and a broken upgrade is worth every release after it — and the person who pays for the broken one can't fix it. --- The uncomfortable summary is that an automated upgrade path is not something you add. It's something you **spend** — on features you decline, forks you delete, patches you refuse to carry, conversations with maintainers who owe you nothing, and sponsored weeks that went into a dependency instead of into the product. I think it's the right thing to spend on, because of who's on the other end. Somebody who wanted a website, got one, and has no idea there's a CMS under it — let alone which one. Every release where nothing broke is a release where they never had to find out. That's the product. The features are just what it does in between. --- # What Started as a Bugfix: Revision Graph 4.0.0 > One ring drawn in the wrong place. Chasing it turned into a rewrite — because Drupal stores no parent pointer for revisions, and "parent" means three different things on a translated node. By Shibin Das · Drupal · Published 18 Aug 2026. Source: https://www.d34dman.com/posts/drupal/revision-graph-4-0-0-recorded-provenance/ Tags: Drupal, Content Creator Tools, Data Modelling, Engineering [Revision Graph](https://www.drupal.org/project/revision_graph) draws a node's revision history the way a Git client draws history: one row per revision, a lane per language, and the branching shown rather than described. **4.0.0** is out, and it started as a bugfix. A node with English, Afrikaans and Bengali translations was drawing wrong. The screen was two panels: a graph on the left, a list of revisions on the right. A revision's facts — is this the current one, the latest, the published one, the live one, the default one — were text chips in the list, and the graph tried to echo them by ringing the dot. It could only echo two of them. `Live` drew a green ring and `Default` drew a dark disc; the other three had no styling at all and drew invisible circles. And both of the marks that *were* drawn landed on one language's dot — whichever language the revision happened to touch. On this node that was Bengali, so the graph read as though Bengali owned the default revision, while the English revision readers were actually served sat there unmarked. *Diagram: How 3.x showed a revision's facts, and how 4.0.0 shows them.* In 3.x the screen was two panels. The graph on the left could only draw two of a revision's facts, as a dark disc for the default revision and a green ring for the live one, both at the same radius on the same dot. The list on the right carried all five facts as text chips: Current, Latest revision, Published, Live and Default. In 4.0.0 there is one panel: the node-wide facts are stated in words above the rail and repeated as a single label on the row they are about. So: stop drawing that ring on a language's dot. One afternoon, surely. Except to know which dot a fact belongs to, you have to know what a dot *is* — and that question turns out to have no clean answer in Drupal. > **Warning:** > > **Upgrading?** Drupal 11 or 12 only — Drupal 10 sites stay on 3.1.1 — and run > `drush updb`. There are breaking changes to the payload, the renderer and the CSS > class names; the > [changelog](https://git.drupalcode.org/project/revision_graph/-/blob/4.x/CHANGELOG.md) > lists them. ## "Parent" means three different things Here is the heart of it, and it is worth knowing even if you never install this module. Ask what a revision's parent is, on a translated node with drafts, and there are three correct answers. *Diagram: Three different right answers to “what is revision 5's parent?”.* A node with five revisions, listed newest first as the rail displays them: a German translation added at 5, an English draft at 4, English published at 3, an English draft at 2, and English published at 1. Revision 5 has three defensible parents. The revision it was built from is 3, not 4, because a draft never becomes the default revision. The language it was copied from is English, which no revision ID can express. And a visitor only ever saw revisions 1, 3 and 5, because 2 and 4 were never published. Answer 1 is the surprising one in practice. When you open a translation form, Drupal builds it from the revision it loads normally — the *default* revision, the last published one. So a translation's parent is usually **not** the newest revision; any draft sitting above it is not in its lineage at all. Answer 2 is why storing a revision number was never going to be enough. German didn't come from revision 3, it came from *English*. A parent here is a **(revision, language) pair**, and half of it is a language. Answer 3 is the one nobody stores, and the one most people picture. A lane in the graph is where content *came from* — not the sequence of versions the public saw. ## Drupal doesn't record any of this **There is no `parent_vid` column.** `node_revision` gives you an ID, a timestamp, an author and a log message, and that is the end of the topology. Revisions are a *list*, not a tree. So a module drawing the tree either keeps the answer itself or works it out afterwards. This one has now done both, in that order. From 2019 we kept our own table, with a parent and a branch name per revision. Getting the parent *into* it was the awkward part, and the commit messages from that week are honest about it: *"introduce a hackish way to store revision parent"*, then *"Bugfix parent version hunt"*. The code read the wrong property and compensated by **parsing the current URL** for `/node/X/revisions/Y/revert` — a storage layer reading a route to work out what it was storing, and blind to any revert not done by hand in the UI. In 2024, [issue #3417493](https://www.drupal.org/i/3417493) with tstoeckler replaced thirteen lines of that with the call that was always the right answer: ```php $parent_version_id = $entity->getLoadedRevisionId(); ``` Then the 3.0.0 refactor dropped the table. The commit is called *"Code cleanup"*, and that is the whole recorded rationale. For the 3.x line the edges were inferred instead — and inference is fine until it isn't: **a revert looks exactly like an edit that happened to restore old values.** Those are precisely the cases a history graph exists for. 4.0.0 records it again, as a base field written at save time. A field rather than a side table because core then manages its life: it travels with the revision and goes when the revision goes. The old table had no delete handling at all, so deleting a revision left its row behind, pointing at something that no longer existed. An edge the module *recorded* is then drawn a pixel heavier than one it merely inferred — a small thing, but it means the graph never claims to know more than it does. **Existing revisions are not backfilled.** They keep their inferred edges. Reconstructing them would mean guessing, and a graph that draws a confidently wrong edge is worse than one that draws fewer. ## What the fix actually was Not a rendering tweak. The dot was carrying facts at three different scopes and had vocabulary for none of them, so each scope got its own channel. *Diagram: A dot answers two independent questions: its ring and its centre.* The ring says whether the revision was ever the default revision: solid for yes, dashed for never. The filled centre says whether it is live right now, meaning it is both this language's current revision and published. Because a language has exactly one current revision, at most one dot per language carries a filled centre, and none does when that language's current revision is unpublished. Facts a shape cannot carry are printed on the row as text labels reading DEFAULT, PENDING or UNPUBLISHED. The two channels are worth stating exactly, because they are easy to conflate. The **ring** is `trunk` — was this revision ever the default revision, the one the site served? The **centre** is `state === 'live'` — is this revision *both* its language's current revision *and* published? The second has a consequence worth knowing before you read a graph: since a language has exactly one current revision, **at most one dot per language can be filled** — and none is, when that language's current revision happens to be unpublished. If you are hunting for what a visitor gets right now, you are looking for one filled dot per lane, and its absence is information too. Facts about the whole node are now **said in words** above the rail — `Default revision #9 · Live now: af #8 · bn #9 · en #5` — and repeated as a **text label on the row they are about**, in a status column, rather than as a mark on one language's dot. That last line is the annoying part. It would have prevented the original misreading on its own, it is the thing an editor opens the page to find out, and it needed none of the rest of this work. But it is only *trustworthy* because of the rest: a header stating what's live, generated by a graph that couldn't say what a dot was, is a sentence I'd have had no reason to believe. ## References - [Revision Graph](https://www.drupal.org/project/revision_graph) on Drupal.org · [source](https://git.drupalcode.org/project/revision_graph) - [`docs/REVISION-MODEL.md`](https://git.drupalcode.org/project/revision_graph/-/blob/4.x/docs/REVISION-MODEL.md) — how Drupal actually stores revisions and translations, and what can and cannot be recovered from it - [A Content Creator's Guide to Revision Graph](https://www.d34dman.com/posts/drupal/revision-graph-guide/) — the earlier, non-technical introduction --- # The LLM Read Every Register. The Answer Was a Smell. > I gave an AI agent full access to a broken robot. It ran better experiments than I would have, ruled out six causes by measurement, and then wrote 'definitively exonerated' about a part it had no way to inspect. Every correction came from something I knew and it structurally could not — and one of its confident errors nearly went out in a warranty claim. By Shibin Das · Engineering · Published 6 Aug 2026. Source: https://www.d34dman.com/posts/engineering/invisible-facts-llm-hardware-debugging/ Tags: llm, debugging, hardware, robotics, reachy-mini, engineering, ai-assisted-development Here is a situation you have probably been in, or will be soon. You hand an AI agent access to a system --- a server, a codebase, a production database --- and ask it to work out why something is broken. It reads everything. It runs commands you wouldn't have thought to run. It comes back with a diagnosis written in complete, confident sentences. How do you know which parts of that to trust? I got an unusually clean answer to that question, because the system I handed over was a *physical* one. When an agent debugs your web app, the boundary between what it can observe and what it can't is fuzzy. When it's debugging a machine with moving parts, that boundary is a wall you can see. The agent could read every electrical value inside my robot, thousands of times a second. It could not know the thing had smelled of burning plastic the week before. That wall turned out to run exactly along the line between the parts of its work that were excellent and the parts that were confidently wrong. *Diagram: What the agent could reach, and what only a human had.* On one side, everything readable over the serial bus: voltage, temperature, current, position, fault flags, firmware. On the other, facts with no sensor and no log: a burning smell, how the leg felt by hand, the order the parts are wired, the usage history, the earlier symptoms, and which startup steps were run. --- ## The broken thing I have a Reachy Mini. It's a small desktop robot, mostly a head on a base. The head doesn't sit on a neck. It sits on six short legs arranged in a ring, each one a little motor that can extend or retract. Push some legs out and pull others in, and the head tilts, turns, rises, nods. Six legs working together give it full freedom of movement. This is a standard bit of robotics geometry called a Stewart platform, and one property of it matters for this story: **the legs are not independent.** They're rigidly linked through the platform, so each one is always working against the other five. Which means a dead leg doesn't cost you one-sixth of the movement. With all six pushing, the head rises level. With one not pushing, that corner has nothing under it --- so the head doesn't just fail to lift, it hangs tilted over toward the dead leg. The other five can't take up the slack, because they're geometrically committed to each other. *Diagram: How the head is held up, and what happens when one leg dies.* The head is the top platform, the same width as the base below it, joined by six short legs. Left: all six push together and the head rises level. Right: leg 5 can no longer push, so that corner drops and the head hangs tilted toward the dead leg instead of lifting. One day the head stopped lifting. It would run its startup routine and just stay down. One LED, on one of the six leg motors, blinked red. The software running the robot logged exactly one useful line before giving up: ``` Motor 'stewart_5' hardware errors: ['Electrical Shock Error'] ``` "Electrical Shock Error" is alarming and misleading. It is not a warning that the robot will shock you. It's the motor manufacturer's name for *the motor detected an electrical fault in itself and shut down to avoid damage.* The motor is protecting itself. It tells you almost nothing about what the fault actually is --- bad power, bad wiring, bad motor, all produce the same three words. So: one motor out of six, self-disabled, reason unknown. I gave an AI agent SSH access to the small computer inside the robot and asked it to find out what was wrong. Over the next few hours it did some of the best diagnostic work I have watched a machine do. It also got the physical answer wrong three separate times. --- ## What it was genuinely good at I want to credit this properly, because the credit is the whole reason the rest of the post is worth reading. This is not a story about a stupid model. These motors aren't dumb actuators. Each one is a small computer that will tell you, on request, its temperature, its supply voltage, its position, how hard it's currently working, and a set of internal fault flags. The agent could interrogate all nine motors in the robot directly. First it took a full reading of all nine and found the fault was isolated to exactly one: ``` id name error volts temp current position 14 stewart_4 none 6.4 26 0 4.8 15 stewart_5 ELECTRICAL SHOCK 6.4 26 0 -48.4 16 stewart_6 none 6.4 27 0 10.2 ``` The important thing here is what's the *same*. The broken motor is getting identical voltage to its neighbours, and it's at identical temperature. It's not starved of power and it's not cooking. It also answered when spoken to, which means the wiring carrying data to it is intact. From there the agent knocked down six suspects, each by measurement rather than assertion: the robot's power supply wasn't dipping, the voltage reaching the motors was correct and even, the communication wiring was healthy, the software configuration was right, the motor wasn't being commanded past its allowed range, the firmware matched its siblings, and nothing was overheating. Then it did something I didn't expect. It designed a controlled experiment. The idea is simple. Ask the motor to move, in tiny increments, while steadily increasing how hard you're driving it --- and watch how much electrical current it draws. **Current is the honest number here.** It's the electrical equivalent of watching how hard someone is actually straining. You can't fake it and the motor can't misreport it. Driving power went up seventeen-fold. The current didn't move at all: ``` step 1 commanded +0.4° actually moved 0.00° current 51 drive power 10 step 8 commanded +3.2° actually moved 0.00° current 51 drive power 60 step16 commanded +6.4° actually moved 0.00° current 51 drive power 117 step24 commanded +9.6° actually moved 0.00° current 51 drive power 175 ``` Think of it as flooring the accelerator and watching the fuel gauge not move. The motor is being told to try seventeen times harder and it is drawing exactly as much electricity as before, and it isn't going anywhere. But a number like that is meaningless in isolation. So the agent ran the *identical* test on one of the five healthy legs, on the same wiring, same power, same code: ``` step 1 commanded +0.4° moved +1.7° current 20 drive power 28 step12 commanded +4.8° moved +5.1° current 35 drive power 68 step24 commanded +9.6° moved +9.9° current 64 drive power 115 ``` That's what working looks like. It goes where it's told, and the harder you drive it, the more electricity it pulls. Put the two on the same axes and the difference stops being a wall of numbers: *Diagram: Current drawn against drive power, healthy leg versus dead leg.* The healthy leg's current climbs from 20 to 64 milliamps as drive power rises from 28 to 115. The dead leg stays pinned at 51 milliamps across a seventeen-fold increase in drive power, and does not move at all. The contrast *is* the finding. And it kills the most intuitive explanation: that the leg was mechanically stuck. A jammed motor is the opposite of this. It pulls *enormous* current --- it's straining against something, and the harder you drive it the more it draws, right up until it burns out. This motor did the reverse. Later tests were starker still: at full drive it pulled **zero current**. Not a strained motor. A motor that isn't electrically there at all. That is a genuinely well-built piece of work. Isolate the variable, build a control, produce a signature that means something. I would not have got there faster by hand. And then it wrote this. --- ## "Definitively exonerated" I asked a simple question: *should I try swapping the cable?* The agent reasoned it through. These are sealed units; the electrical activity that actually drives the motor happens inside the sealed body and never travels down the cable; the cable only carries power and instructions. Therefore no cable fault could produce this signature. Therefore the cable was **"definitively exonerated."** It advised me not to bother checking. The reasoning was clean. The confidence was unearned. Its supporting argument was that zero current proves nothing is being lost in the cable. But that zero was measured *after* the motor died. A cable that had *caused* the failure would produce exactly the same zero afterwards. The argument ran backwards in time, and no amount of additional measurement would have caught it, because the error wasn't in the measurement. I want to be precise about the failure mode, because it's the point of the post. The agent did not have bad data. It had far better data than I did. What it did was attach the word *definitively* to a claim about the physical condition of an object it could not see, touch, or open --- on the strength of measurements that were entirely correct, but that supported a much weaker claim than the one it made. It did this three times. And each time, the correction came from me, and none of my corrections were technical. --- ## Three facts that were never going to be in the data **The first was a smell.** In the days before the final failure, the robot had smelled of burning electrics. I mentioned this late, and only in passing, because it hadn't occurred to me that it was a *diagnostic* fact. It was just a thing I'd noticed. It reframed the entire case. The problem was no longer "a motor stopped working." It was "something burned." That gives you a physical event, a location, and a mechanism, and the agent's whole model reorganised around it. **The second was how it felt in my hand.** The agent asked me to disconnect the power and move the dead leg by hand, and tell it whether the leg felt stiffer than the others. It didn't. It felt completely normal. That sounds like a nothing observation. It isn't --- and it's the one place where the agent's electrical knowledge and my hands genuinely combined. There are two ways a motor like this dies: - **Shorted.** Its internals fuse together, the motor becomes its own brake, and the shaft gets noticeably hard to turn. - **Open.** Its internals burn *through*. Nothing is connected to anything, and the shaft spins freely --- exactly like a healthy one at rest. Normal to the touch meant open, not shorted. And an open circuit was compatible with a cable fault --- so my hands revived the hypothesis the agent had "definitively" killed. It withdrew the backwards-in-time argument. **The third closed the case, and it wasn't a measurement at all.** I eventually told the agent something I knew only because I'd had the robot open: the motors aren't wired to a central hub in a star. They're **daisy-chained.** Old Christmas lights. Motor 4 connects to motor 5, motor 5 connects to motor 6. Power and instructions for motor 6 physically pass *through* motor 5 --- in one of its sockets, across a track inside its body, and out the other. Motor 6 works perfectly. Full range, both directions, drawing healthy current the whole way. *Diagram: The motors are daisy-chained, so leg 6 is powered through leg 5.* Power and data run in series from the Pi through leg 4, then through leg 5's connectors and internal pass-through, then to leg 6. Leg 6 works and draws 64 to 75 milliamps, which proves the whole shared path is intact. Only leg 5's own drive stage, which is not shared, remains as the fault. Follow what that proves. Every electron reaching motor 6 has already travelled down the cable to motor 5, through both of motor 5's connectors, and through motor 5's internals. If any of that were faulty, motor 6 would be dead too. And motor 6 draws *more* current than motor 5 fails at. So the shared path isn't just intact --- it's proven good at a heavier load than the one that kills motor 5. Which leaves exactly one thing that isn't shared: motor 5's own drive circuitry, downstream of the pass-through. That single conclusion accounts for every observation at once --- communication fine, readings fine, no movement, no current under load. The cable was excluded at last, on sound grounds. Now notice what that fact *was*. Not a sensor reading. Not a log line. Not anything in the manufacturer's documentation, which doesn't mention it. It's **the order the parts are physically wired in** --- and the only reason it existed anywhere was that a human had looked inside the box. --- ## The silence was nobody looking One more failure, and this is the one that generalises furthest beyond robots. On a fresh restart, the agent checked the logs and found *zero* hardware errors. The supervising software hadn't flagged motor 5 the way it had on every previous boot. Reasonable reading: something changed, maybe it recovered. It then read the motor directly. Still faulted. Still dead. The explanation was operational and I had to supply it. I hadn't launched the app that puts the robot into its active state, so the supervising software never got as far as checking the motors. There was nothing in the log because **nothing had looked.** That's the shape of the mistake, and it has nothing to do with hardware. An agent reading your logs will treat absence as evidence unless something tells it the instrument was switched off. Empty error log, monitoring that never fired, a clean dashboard --- each of these has two possible causes, and only one of them is "everything is fine." It's also why the best evidence in this whole case was gone before I started. The robot's system logs don't survive a reboot; only the current session is kept. So the entire lead-up --- weeks of it --- existed nowhere but in my head: the head driving up and sticking there mid-routine, the robot spontaneously rebooting, several times, and then the smell. If your system discards its history, the operator's memory *is* the log. It is a bad log. It is the only one you have. --- ## Don't forward it as proof There's a near-miss in this story that bothers me more than any of the wrong conclusions, because the wrong conclusions only cost *me* time. At one point the agent drafted a warranty email to the manufacturer. Inside it was a clean, specific, technical-sounding assertion: that the fault **re-latches on its own with torque disabled**. It then built an argument on top of that. It wasn't true. The agent's own later test --- 185 seconds of polling with torque never enabled --- showed no re-latch at all. To its credit it caught this itself and retracted the claim. But it caught it *after* writing it into a document addressed to someone else. If I had sent that email, I would have asserted a symptom that never happened, to a manufacturer, as part of a claim. And the person on the other end would have had no way to know. They'd have done the right thing --- taken it seriously, tried to reproduce a behaviour that doesn't exist, and burned their afternoon on it. The same goes for **"definitively exonerated."** Sent to support, that phrase would have argued *against* the vendor's own documented procedure --- which asks you to check the cables --- using a conclusion the agent had invented. Notice where the cost lands. I get the speed. Someone else gets a confident, fluent, well-formatted document they cannot audit, because **the prose gives no indication of which sentences were measured and which were guessed.** It reads uniformly authoritative all the way through. That's precisely the property that makes it dangerous once it leaves your hands. Open source maintainers are already living this, buried in LLM-written bug reports and security disclosures that are articulate, plausible, and hollow. Support engineers are next. Every one of those costs a real person real hours, and the sender usually never finds out. So: use it for discovery. It is genuinely excellent at generating hypotheses, designing experiments, and telling you what you haven't checked yet. That's the job. > But when something goes *out* --- a support ticket, a warranty claim, a bug report, an incident writeup, a message to a colleague --- send **measurements, not conclusions.** Numbers, commands, and raw output the recipient can re-run. Keep the narrative in your own voice, and assert only what you would be willing to defend yourself, out loud, without the agent in the room. If you wouldn't stake your own name on a sentence, don't make someone else spend their day on it. --- ## What I'd take from this **Confidence doesn't shrink when the evidence runs out.** The word "definitively" appeared in a written report about a component the agent could not see. The measurements underneath it were correct. That's what makes it dangerous --- there's no visible seam. The reasoning stays articulate right across the boundary from *measured* to *guessed*, and nothing in the tone marks the crossing. **So ask where the boundary is.** "Which of these claims did you measure, and which did you infer?" is a cheap question and it partitions the report exactly where the risk lives. Every wrong turn in this case was in the inferred layer. The measured layer held up start to finish --- including the zero-torque test above, where it disproved its own written claim unprompted. It could falsify itself, but only inside the region it could reach, and only after the claim was already on paper. **Volunteer the things that don't seem relevant.** The smell and the pre-failure reboots arrived last and were worth more than every measurement combined. I wasn't withholding them. They just didn't feel like *technical* facts. Smell, sound, how something feels to move, "it used to do this," "I skipped the usual startup today," "I've had it open, and here's how it's put together." That last category is the one people never think to mention and it's the one that closed this case. None of this is specific to robots. The software equivalents are everywhere: - Someone restarted that box by hand last Tuesday. - This config was edited in the console, not in the repo. - The customer said it started right after the power cut. - That queue has always been slow on Mondays. Nobody logged any of it. And the agent will never ask, because it doesn't know the question exists. **The split was real, and it wasn't even.** The machine was better than me at breadth and rigour --- nine devices in one pass, a proper control, a seventeen-fold ramp with current sampled throughout. I was better at everything physical and everything historical. Neither of us gets to the answer alone. But of the two of us, the facts that actually decided it were all mine, and not one of them required any expertise. I smelled something. I touched something. I remembered something. I'd looked inside the box. One caveat, stated plainly: the detailed technical report this post draws on was written by the same agent that made the mistakes, which means it is partly grading its own work. I've kept the measurements, which are checkable, and I was the one supplying every correction. Weigh the self-assessment accordingly. The motor has a burned-out drive stage. It's under warranty. --- ## Appendix --- the technical detail *For anyone who landed here searching `Electrical Shock Error` on a Dynamixel XL330. This is the part I wrote down so you wouldn't have to repeat it.* ### Verdict The output stage of `stewart_5` (XL330-M288-T, ID 15) has **burned open**. It is not software, configuration, calibration, cable, connector, bus or power supply. Rebooting and reflashing will not fix it. The motor needs replacing. The signature: **no torque at all, and 0 mA drawn at non-zero PWM.** That's an open circuit --- not a jam, and not a short. ### Setup | | | | --- | --- | | Robot | Reachy Mini Wireless, software v1.9.0 | | Host | Raspberry Pi CM4, kernel 6.12.62 | | Motors | Dynamixel XL330-M288-T | | Bus | `/dev/ttyAMA3`, 1 Mbaud, via `rustypot`'s `Xl330PyController` | | IDs | `10` body rotation · `11--16` stewart_1..6 · `17`/`18` antennas | | Faulty unit | `stewart_5` = **ID 15** | | Control unit | `stewart_4` = **ID 14** | ### What the error actually is - Register `70` is Hardware Error Status. Bit 4 (`0x10`) is Electrical Shock. - `shutdown_error: 52` = bits 2, 4, 5 (Overheating, Electrical Shock, Overload) are configured as **latching** shutdowns. - Once latched, the motor kills its own torque and blinks red until rebooted or power-cycled. - A Dynamixel reboot clears `0x10` → `0x00`, so the unit is **not bricked**. It re-trips within about six steps of any real load. ### Ruled out by measurement | Suspect | Finding | | --- | --- | | Pi power / brownout | `vcgencmd get_throttled` = `0x0`; no undervoltage in `dmesg` | | Motor supply rail | 6.4--6.5 V on every motor; ID 15 identical to healthy legs | | Serial bus / comms | All nine ping; control loop stable ~49.6 Hz, `nb_error: 0` | | Software / config | All six legs already correctly configured; `shutdown=52`, `mode=3` | | Position limits | ID 15 at raw `1492` vs floor `1252` → ~21° headroom, not clamped | | Firmware | v53 across all six legs, uniform | | Overheat / overload | Only bit 4 set; 26 °C; no overload bit | > **Don't chase the 6.4 V rail.** It looks high for an XL330 and it's an easy false lead. From Pollen's own docs: *"We are using a higher voltage on Reachy Mini, it's on purpose :)"* ### Test 1 --- PWM ramp *Does current respond to drive?* Slow 0.4°/step ramp, PWM raised 17× (10 → 175, ~20% duty). **Result:** zero motion across 9.6° of command with ~21° of headroom available. Current pinned flat at 51 mA, peak 52 mA. **Reading:** rules out a mechanical jam. A jammed XL330 at PWM 175 would pull hundreds of mA climbing toward its 1.75 A limit, and current would scale with PWM. This did neither. ### Test 2 --- supply sag *Is the rail collapsing under load?* Ramp both legs while continuously sampling input voltage at the motor. ``` === id15 dir=+1 === tripped step 2 pwm=18 cur= 0mA volt 6.4-6.6V === id15 dir=-1 === tripped step 1 pwm=-5 cur= 0mA volt 6.1-6.5V === id14 dir=+1 === 24 steps OK pwm=115 cur= 64mA volt 6.4-6.5V === id14 dir=-1 === 24 steps OK pwm=-118 cur=-66mA volt 6.3-6.5V ``` **Reading:** 0 mA at non-zero PWM is the signature of an open winding or a dead output stage. The healthy control pulled *more* current with *less* voltage variation. **Caveat:** each voltage sample is a serial round-trip of roughly a millisecond, and ID 15 tripped within a few hundred milliseconds. This rules out a *sustained* sag, not a sub-millisecond one. ### Test 3 --- zero torque *Does it fail on its own, or only under load?* Latch cleared, then ID 15 polled every 5 s for 185 s with torque **never enabled**. **Result:** no spontaneous re-latch. **Reading:** the fault is load-dependent, not present at rest. This is also the test that falsified the agent's own earlier written claim that the fault re-latches with torque disabled. ### Why the cable is excluded Dynamixel chains bridge VDD, GND and data across a motor's two ports. All power and data for ID 16 therefore passes through ID 15's connectors and its internal pass-through traces. 1. ID 16 completes 24/24 ramp steps, both directions, drawing 64--75 mA. 2. So the cable feeding ID 15, both connector contacts, and ID 15's pass-through are sound **and carrying current**. 3. They carry it at 75 mA --- above the 0--52 mA range in which ID 15 fails. 4. What remains is only what is *not* shared: ID 15's own drive stage, downstream of the pass-through. That accounts for every observation at once --- comms fine, register reads fine, 0 mA at non-zero PWM, no torque, error latching under drive and absent at rest. ### Likely mechanism **This part is inference about the inside of a sealed servo, not measurement.** Winding insulation degrades under cumulative thermal cycling into a turn-to-turn short. From there: - A motor drawing abnormal current and unable to hold torque makes the head stick --- one bad leg leaves the other five over-constraining a parallel platform. - The current spike browns out the CM4, causing the spontaneous reboots. - The short burns through (the smell), then goes open (the measured state). - An open circuit draws nothing, which is why both the smell and the reboots stopped. - Twenty-plus power cycles since, with no recurrence. The measurements are the durable part. --- # Your llms.txt Isn't Being Ignored — Your Analytics Just Can't See It > A recurring claim: 'I added llms.txt and nothing reads it — zero hits.' Almost every time, the zero comes from checking the wrong instrument. Client-side analytics is structurally blind to a plaintext file fetched by a bot. Here's why, and where the real answer lives. By Shibin Das · Engineering · Published 22 Jul 2026. Source: https://www.d34dman.com/posts/engineering/llms-txt-analytics-blind-spot/ Tags: analytics, llms-txt, web, observability, seo, engineering There's a claim I keep running into, in blog posts and in threads and in the confident voice of someone who has Just Checked: > "I added an `llms.txt`. Nothing reads it. Zero hits. The whole idea is snake oil." I want to take that apart --- not to defend `llms.txt`, which may or may not deserve defending, but because the *evidence* being offered is almost always broken. The person saw a zero. The zero was real. The zero meant nothing. The mistake isn't the conclusion. It's the instrument. --- ## What llms.txt Is, Briefly `llms.txt` is a proposed convention: a Markdown file at the root of your domain (`/llms.txt`) that gives large language models a curated, low-noise map of your site --- what the pages are, which ones matter, where the canonical docs live. Think `robots.txt`'s intent, but written for a reader that wants prose instead of crawl rules. Whether AI systems actually consume it at scale is a live, unsettled debate --- and an honest one. This post is not that debate. This post is about the much smaller, much more common error people make *while trying to answer* that debate for their own site. They open their analytics dashboard, search for `/llms.txt`, find nothing, and file it under "confirmed useless." That dashboard was never going to show them anything. Not because the file wasn't fetched --- because of how the dashboard works. --- ## How Client-Side Analytics Actually Records a Hit Google Analytics, Plausible, Umami, Fathom, Matomo-in-JS-mode --- the whole client-side family --- all work the same way underneath. They do **not** watch your server. They watch *browsers*. The mechanism is a snippet: ```html ``` The chain of events that produces one recorded "pageview" is: 1. A browser requests an **HTML page**. 2. The browser parses the HTML and **executes the JavaScript** in it. 3. That script runs, reads some `document` and `navigator` state, and **fires a beacon** back to the analytics collector. 4. The collector records the hit. Every step after the first is a browser doing browser things. Take any one of them away and the hit never exists as far as the dashboard is concerned. Now look at what happens when an AI crawler --- `GPTBot`, `ClaudeBot`, `PerplexityBot`, `OAI-SearchBot`, `Google-Extended`, or a plain `curl` from someone's agent script --- fetches your `llms.txt`: 1. It issues a raw HTTP `GET /llms.txt`. 2. It reads the bytes. 3. It leaves. No browser. No JavaScript engine. No beacon. The fetch happened, the bytes went out over the wire, your server logged a `200` --- and your analytics recorded *nothing*, because nothing in that transaction ever ran the code that analytics depends on. --- ## And It's Worse Than That: A .txt File Can't Even Hold the Snippet Here's the part that should end the argument on its own. Client-side analytics needs its `