[{"data":1,"prerenderedAt":820},["ShallowReactive",2],{"post-\u002Fblog\u002Ftwo-hands-on-one-lever":3,"blog-all":319},{"id":4,"title":5,"author":6,"body":7,"category":300,"date":301,"description":302,"draft":303,"extension":304,"image":305,"meta":306,"navigation":307,"path":308,"project":309,"readingMinutes":310,"seo":311,"series":312,"seriesOrder":312,"stem":313,"tags":314,"__hash__":318},"blog\u002Fblog\u002Ftwo-hands-on-one-lever.md","Two Hands on One Lever","EkoHacks Team",{"type":8,"value":9,"toc":290},"minimark",[10,21,25,33,36,41,44,47,51,54,141,144,148,151,157,163,169,173,181,184,191,197,203,206,210,213,219,222,225,231,234,240,243,247,250,266,269,273,276],[11,12,18],"pre",{"className":13,"code":15,"language":16,"meta":17},[14],"language-text","Triggering deploy...\nFailed\nError: A deploy is already in progress for this service\nError: Process completed with exit code 1.\n","text","",[19,20,15],"code",{"__ignoreMap":17},[22,23,24],"p",{},"On the morning of 22 July, the Dojo's deploy workflow failed with the message above. Nothing was wrong in production. The commit it was refusing to deploy went live twenty seconds later anyway. No user saw an error, no data was lost, and the site never blinked.",[22,26,27,28,32],{},"By that afternoon, our own dashboard showed a red build on trunk, the author's green streak was gone, and the failing check was named: ",[29,30,31],"strong",{},"tests",". The tests had passed.",[22,34,35],{},"This is an anatomy of that collision. It is worth writing down precisely because nothing broke. Incidents with damage get studied; incidents without damage get shrugged off, and the shrug is how the same shape returns later with damage attached.",[37,38,40],"h2",{"id":39},"the-system-before-the-incident","The system, before the incident",[22,42,43],{},"The Dojo deploys two services from one repository. The web frontend keeps the platform's own push trigger: every push to main builds it immediately, because a stale or broken frontend build produces no image and therefore no deploy, and a preview build of it touches nothing. The server is different. A preview of the server would boot with the production environment, including the production database, so a branch's migrations would run against real data. Its automatic trigger is switched off, and a workflow deploys it instead: when CI completes green on a main commit, the workflow asks the platform to deploy exactly that commit, guarded by a staleness check and a concurrency group.",[22,45,46],{},"The workflow's own header comment says it plainly: this is the only deploy path for the server. At the time of the incident, that sentence had been true for less than a day.",[37,48,50],{"id":49},"the-incident","The incident",[22,52,53],{},"All times are UTC, 22 July 2026, reconstructed from the platform's activity log and the CI run history.",[55,56,57,70],"table",{},[58,59,60],"thead",{},[61,62,63,67],"tr",{},[64,65,66],"th",{},"Time",[64,68,69],{},"Event",[71,72,73,86,94,105,115,123,133],"tbody",{},[61,74,75,79],{},[76,77,78],"td",{},"11:13:40",[76,80,81,82,85],{},"A pull request lands on main as commit ",[19,83,84],{},"5a87925",". The platform's push trigger starts building the web service. CI starts on the commit.",[61,87,88,91],{},[76,89,90],{},"11:13:46",[76,92,93],{},"The web service is live on the new commit.",[61,95,96,99],{},[76,97,98],{},"11:17:14",[76,100,101,102,104],{},"A manual deploy of the server begins. No commit is pinned to it, so it builds the tip of main, which is ",[19,103,84],{},".",[61,106,107,110],{},[76,108,109],{},"11:17:33",[76,111,112,113,104],{},"CI concludes green. The deploy workflow fires and asks the platform to deploy ",[19,114,84],{},[61,116,117,120],{},[76,118,119],{},"moments later",[76,121,122],{},"The platform refuses: a deploy is already in progress. The workflow exits non zero.",[61,124,125,128],{},[76,126,127],{},"11:17:44",[76,129,130,131,104],{},"The manual deploy goes live. Production is now running ",[19,132,84],{},[61,134,135,138],{},[76,136,137],{},"11:22:42",[76,139,140],{},"A human notices the deployment marker never landed, and backfills it by hand.",[22,142,143],{},"The manual deploy at 11:17:14 was a person doing what people had done every day before the workflow existed: merge, then reach for the lever. An identical manual deploy had happened at 10:00 the same morning and collided with nothing, because that time the automation arrived four minutes later rather than nineteen seconds. The near miss and the hit were the same act; only the timing differed.",[37,145,147],{"id":146},"analysis","Analysis",[22,149,150],{},"Three findings, in decreasing order of comfort.",[22,152,153,156],{},[29,154,155],{},"First, production was correct throughout."," The manual deploy built the tip of main after the commit had merged, so the code the workflow wanted live was exactly the code that went live. The refused trigger changed nothing about the outcome, only about who was recorded as having caused it.",[22,158,159,162],{},[29,160,161],{},"Second, the platform's lock behaved correctly."," Its guarantee is at most one deploy per service at a time, and its mechanism is refusal. A lock serialises; it does not coordinate. It cannot know that the two contenders wanted the same thing, and it is not its job to know. When both hands pull the lever the lock picks a winner, and the loser's only information is the word no.",[22,164,165,168],{},[29,166,167],{},"Third, the failure was architectural, and it was ours."," Two actors held the same authority over the same service with no shared understanding between them beyond that lock. The workflow believed it was the only deploy path because its comment said so. The human's muscle memory predated the comment. Automation that replaces a manual practice does not end that practice; it competes with it, and habits have very low latency.",[37,170,172],{"id":171},"what-the-dashboard-said-and-why-that-is-the-interesting-part","What the dashboard said, and why that is the interesting part",[22,174,175,176,178,179,104],{},"The Dojo watches its own development. Its Recent Builds panel that afternoon read: twenty builds, two red, trunk health 89 percent, eight green of nine trunk builds. And on the row for ",[19,177,84],{},": status RED, failing checks, ",[29,180,31],{},[22,182,183],{},"That row makes three claims. Examine each.",[22,185,186,190],{},[187,188,189],"em",{},"The build is red."," Under our verdict model this is, strictly, correct behaviour. Any workflow that fails on a commit marks the build red, and a later failure overturns an earlier green, because a check that fails after the green means the build never really was green. The deploy workflow's conclusion arrived through the same webhook as every other workflow's, and the model did what it was designed to do.",[22,192,193,196],{},[187,194,195],{},"Trunk health dropped."," This follows from the first claim, and it carried consequences: the overturn reset the author's green streak and reversed the XP the green build had awarded. The gamification layer treated a lost race between two deploy paths as a developer breaking trunk.",[22,198,199,202],{},[187,200,201],{},"The tests failed."," This one is false, and its origin is worth tracing, because nobody wrote a line that says blame the tests. The row that stores a verdict has one boolean per check that CI reports. The code that concludes a build sets the tests boolean to false on any red verdict, a shortcut that was harmless when the only workflows able to conclude a build were the ones that run the tests. The UI then, reasonably, names whichever checks reported false. Each decision was locally sensible. Composed, they laundered a deployment orchestration failure into a named accusation against a check that had passed.",[22,204,205],{},"The general lesson: an observability system inherits the ontology of its writers. Ours had one category, a verdict on the commit, so a failure that was actually a verdict on the delivery attempt got filed under the only heading that existed. Integration signal and delivery signal are different measurements. A commit can be perfectly integrated and fail to ship; it can also ship while its build burns. Storing both in one boolean guarantees that one of them will eventually lie.",[37,207,209],{"id":208},"mitigation-as-pseudocode","Mitigation, as pseudocode",[22,211,212],{},"The root fix is a change of grammar. A deploy trigger phrased as an imperative, start a build now, fails when anyone else is mid sentence. Phrased as a declaration, this commit should be live, it cannot collide, because a rival achieving the same state is success, not failure.",[11,214,217],{"className":215,"code":216,"language":16,"meta":17},[14],"procedure ENSURE-DEPLOYED(service, C):        # C = tested tip of main\n    if TIP(main) != C:\n        return SKIPPED-STALE                  # a newer commit will bring its own run\n\n    for attempt in 1..N:\n        D \u003C- newest deploy of service, in flight or finished\n\n        if D.branch = main and D.created-at >= PUSH-TIME(C):\n            wait until D finishes\n            if D is live:\n                return SUCCESS(adopted = D)   # someone shipped our state; losing\n                                              # the race is winning the outcome\n        if service is busy:\n            sleep(backoff); continue\n\n        TRIGGER(service, C)                   # a refusal here means we lost a\n                                              # race that started this instant;\n                                              # loop and adopt the winner\n    return FAILURE\n",[19,218,216],{"__ignoreMap":17},[22,220,221],{},"Two properties are worth stating precisely. The procedure is idempotent: run it twice, or run two of it concurrently, and the system converges on the same state with neither run failing. And it is adoptive: a rival deploy that covers the desired state is counted as this run's success, so the refusal that opened this post becomes an observation, not an error.",[22,223,224],{},"The deployment marker follows the same grammar. On the day, the marker was gated on our own trigger step succeeding, so a deploy that reached production without us left a hole in the DORA data that a human had to patch.",[11,226,229],{"className":227,"code":228,"language":16,"meta":17},[14],"if ENSURE-DEPLOYED(service, C) = SUCCESS:     # own or adopted\n    POST deployment-marker(C)                 # record the state reached,\n                                              # not the actor who reached it\n",[19,230,228],{"__ignoreMap":17},[22,232,233],{},"And the telemetry stops filing delivery under integration:",[11,235,238],{"className":236,"code":237,"language":16,"meta":17},[14],"on workflow-run completed for commit C:\n    if workflow is a delivery workflow:\n        record deployment outcome for C       # its own stream, its own panel\n    else:\n        conclude build for C, attributing the workflow by name\n\non concluding a build RED:\n    set only the check booleans this workflow actually reports;\n    an unexplained red renders as unattributed, never as a named check\n",[19,239,237],{"__ignoreMap":17},[22,241,242],{},"The unattributed rendering already exists in our UI; the writer simply never gave it the chance to appear.",[37,244,246],{"id":245},"what-we-will-change","What we will change",[22,248,249],{},"Four changes, in the order we trust them.",[251,252,253,257,260,263],"ol",{},[254,255,256],"li",{},"The deploy workflow adopts the convergence loop above: bounded retries, and a lost race that covers the same commit is recorded as success.",[254,258,259],{},"The deployment marker keys on the state reached, not on which actor's step reached it.",[254,261,262],{},"Delivery workflows stop concluding builds. Their failures land in a deployment stream, where a red mark would have read, accurately, deploy trigger refused, commit already shipping.",[254,264,265],{},"Policy: nobody touches the manual lever for the server now that the workflow owns it.",[22,267,268],{},"The list is ordered by trust deliberately. The fourth item is the one every postmortem writes first and the only one with no enforcement mechanism. We state it, and we do not lean on it; items one to three are what make item four unnecessary rather than merely requested.",[37,270,272],{"id":271},"conclusion","Conclusion",[22,274,275],{},"A lock refuses; it does not coordinate. An automation that replaces a habit competes with that habit for as long as the habit's hands remember the lever. And a refused command is information about a rival's progress, which a well written trigger treats as an outcome to adopt rather than an error to report.",[22,277,278,279,284,285,289],{},"The quieter finding is about measurement. Nothing in production failed, and still the incident cost a streak, an XP award, and the dashboard's honesty, because one boolean was asked to carry two meanings. We have written before about writes that are ",[280,281,283],"a",{"href":282},"\u002Fblog\u002Fsafe-to-run-twice","safe to run twice"," and about a gate that ",[280,286,288],{"href":287},"\u002Fblog\u002Fthe-gate-went-red-and-it-was-right","went red and was right",". This gate went red and was half right: right that a workflow failed, wrong about what that meant, and wrong about whom to blame. The fix is not a greener dashboard. It is a dashboard whose reds mean what they say.",{"title":17,"searchDepth":291,"depth":291,"links":292},2,[293,294,295,296,297,298,299],{"id":39,"depth":291,"text":40},{"id":49,"depth":291,"text":50},{"id":146,"depth":291,"text":147},{"id":171,"depth":291,"text":172},{"id":208,"depth":291,"text":209},{"id":245,"depth":291,"text":246},{"id":271,"depth":291,"text":272},"Shipping and Operations","2026-07-23T09:00:00.000Z","Our deploy failed with a deploy already in progress while the commit it wanted to ship was going live. Anatomy of a benign collision and a trigger safe to lose.",false,"md","\u002Fimages\u002Fblog\u002Ftwo-hands-on-one-lever-hero.jpg",{},true,"\u002Fblog\u002Ftwo-hands-on-one-lever","The Dojo",7,{"title":5,"description":302},null,"blog\u002Ftwo-hands-on-one-lever",[315,316,317],"operations","idempotency","observability","xayQTkQHeZ-Vu8jnDg9FjFv4UiXZOwwXTFaXvUec-pc",[320,332,343,351,363,370,378,385,392,401,409,418,425,432,445,454,461,468,477,485,493,503,509,517,525,531,537,546,554,564,572,579,590,598,604,614,623,631,639,647,655,661,669,677,684,690,697,706,714,721,728,737,743,751,757,759,765,774,781,788,796,802,808,814],{"path":321,"title":322,"description":323,"date":324,"author":6,"image":325,"readingMinutes":326,"category":327,"project":309,"tags":328,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fa-branch-is-inventory","A Branch Is Inventory","Unintegrated code is stock in a warehouse, and it depreciates. Our branch was one merge behind the trunk and that was enough for a conflict. What waiting costs.","2026-07-09T09:38:00.000Z","\u002Fimages\u002Fblog\u002Fa-branch-is-inventory-hero.jpg",6,"Craft and Practice",[329,330,331],"git","continuous-integration","craft",{"path":333,"title":334,"description":335,"date":336,"author":6,"image":312,"readingMinutes":310,"category":300,"project":337,"tags":338,"draft":303,"series":341,"seriesOrder":342},"\u002Fblog\u002Fa-map-drawn-by-the-build","A Map Drawn by the Build","Thirty six of our pages had never been visited by Google because nothing said they existed. The sitemap is drawn from the build, and it found a draft.","2026-08-22T20:30:00.000Z","ekohacks.com",[339,315,340],"seo","nuxt","From Invisible to Findable",3,{"path":344,"title":345,"description":346,"date":347,"author":6,"image":312,"readingMinutes":326,"category":300,"project":337,"tags":348,"draft":303,"series":341,"seriesOrder":350},"\u002Fblog\u002Fa-privacy-policy-you-can-diff","A Privacy Policy You Can Diff","Our legal pages are files in the same repository as the site. Each now lists every change to its text, with the date and the diff, drawn from git at build time.","2026-08-23T16:00:00.000Z",[349,315,340],"privacy",9,{"path":352,"title":353,"description":354,"date":355,"author":6,"image":356,"readingMinutes":310,"category":357,"project":358,"tags":359,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fan-off-switch-not-a-disguise","An Off Switch, Not a Disguise","A Nullable differs from a stub in whether production code can tell the fake is there. Finding the word fixed a socket wrapper that had been lying to its tests.","2026-07-20T09:05:00.000Z","\u002Fimages\u002Fblog\u002Fan-off-switch-not-a-disguise-hero.jpg","Testing and TDD","EkoLite",[360,361,362],"nullables","testing","websocket",{"path":364,"title":365,"description":366,"date":367,"author":6,"image":368,"readingMinutes":369,"category":327,"project":312,"tags":312,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fbuilding-engineering-teams-that-learn-together","Building Engineering Teams That Learn Together","The strongest engineering teams are not the ones with the most talented individuals. They are the ones that have learned how to learn together.","2026-02-05T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fteams-that-learn-together-hero.jpg",4,{"path":371,"title":372,"description":373,"date":374,"author":6,"image":375,"readingMinutes":369,"category":300,"project":309,"tags":376,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fderived-state-recompute","The Fix Was Merged, but the Numbers Were Still Wrong","We fixed a scoring bug, reviewed it, merged it, and production still showed the old numbers. Derived data is a cache that goes stale, and it must be recomputed.","2026-07-08T21:43:55.000Z","\u002Fimages\u002Fblog\u002Fderived-state-recompute-hero.jpg",[315,377,316],"deployment",{"path":379,"title":380,"description":381,"date":382,"author":383,"image":384,"readingMinutes":326,"category":300,"project":312,"tags":312,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fdockerising-nestjs-nuxt-monorepo-with-pnpm","Dockerising a NestJS and Nuxt Monorepo with pnpm","Dockerising a NestJS and Nuxt monorepo with pnpm: separate multi stage Dockerfiles for development and production, one Compose file, reproducible environments.","2026-02-09T09:00:00.000Z","Ogochukwu Okpala","\u002Fimages\u002Fblog\u002Fdocker-monorepo-hero.png",{"path":386,"title":387,"description":388,"date":389,"author":6,"image":312,"readingMinutes":310,"category":300,"project":337,"tags":390,"draft":303,"series":341,"seriesOrder":391},"\u002Fblog\u002Ffifty-posts-and-an-empty-robots-txt","Fifty Posts and an Empty robots.txt","Our site scored 100 for SEO and nobody could find it. The audit that started a series on making a site findable, and on what measuring visitors costs them.","2026-08-21T14:30:00.000Z",[339,315,331],0,{"path":393,"title":394,"description":395,"date":396,"author":6,"image":397,"readingMinutes":369,"category":327,"project":309,"tags":398,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Ffrom-convention-to-plugin","From Convention to Plugin","A five step procedure for webhook signature checks, easy to forget and silent when you did, became one Fastify plugin. The safe path is now impossible to skip.","2026-04-17T08:26:12.000Z","\u002Fimages\u002Fblog\u002Ffrom-convention-to-plugin-hero.jpg",[331,399,400],"fastify","security",{"path":402,"title":403,"description":404,"date":405,"author":6,"image":406,"readingMinutes":407,"category":300,"project":309,"tags":408,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fintegrate-against-the-trunk","Integrate Against the Trunk, Not Your Copy of It","A rebase onto a stale local main succeeds, looks resolved, and leaves the pull request conflicting. Everything on your machine is a photograph of the trunk.","2026-07-09T09:22:00.000Z","\u002Fimages\u002Fblog\u002Fintegrate-against-the-trunk-hero.jpg",5,[329,330,315],{"path":410,"title":411,"description":412,"date":413,"author":6,"image":414,"readingMinutes":369,"category":327,"project":309,"tags":415,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Flandmines","Landmines","Most CLAUDE.md files grow because documenting a convention feels like progress. Ours holds landmines only: facts the code cannot tell you, costly to step on.","2026-04-17T10:15:06.000Z","\u002Fimages\u002Fblog\u002Flandmines-hero.jpg",[331,416,417],"documentation","ai-agents",{"path":419,"title":420,"description":421,"date":422,"author":6,"image":423,"readingMinutes":326,"category":300,"project":309,"tags":424,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fnever-break-the-build","Never Break the Build","If the software worked five minutes ago, only five minutes of change can be to blame. What a green trunk buys, who pays for it, and a check that could not fail.","2026-07-09T09:54:00.000Z","\u002Fimages\u002Fblog\u002Fnever-break-the-build-hero.jpg",[330,315,361],{"path":426,"title":427,"description":428,"date":429,"author":6,"image":312,"readingMinutes":310,"category":300,"project":337,"tags":430,"draft":303,"series":341,"seriesOrder":291},"\u002Fblog\u002Fone-address-per-page","One Address per Page","Every page on our site answered with a redirect before it answered with a page. How we made the build emit one address, declare it, and check it live.","2026-08-22T18:30:00.000Z",[339,315,340,431],"netlify",{"path":433,"title":434,"description":435,"date":436,"author":6,"image":437,"readingMinutes":369,"category":438,"project":439,"tags":440,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fone-command-three-policies","One Command, Three Policies, Zero Duplication","Preflight, cut and ship each worked alone before one command composed them. Why the composition layer is forty lines, and how the scrollback became the record.","2026-07-21T17:20:00.000Z","\u002Fimages\u002Fblog\u002Fone-command-three-policies-hero.jpg","Design and Architecture","The EkoHacks CLI",[441,442,443,444],"composition","cli-design","narration","encoding-the-release",{"path":446,"title":447,"description":448,"date":449,"author":6,"image":450,"readingMinutes":310,"category":438,"project":358,"tags":451,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fone-way-to-fail","One Way to Fail","Closing the convergence so an upload fails in the same error shape as every other call, mostly by deleting the code that made the two shapes differ.","2026-07-20T10:05:00.000Z","\u002Fimages\u002Fblog\u002Fone-way-to-fail-hero.jpg",[452,360,453],"design","error handling",{"path":455,"title":456,"description":457,"date":458,"author":6,"image":459,"readingMinutes":326,"category":300,"project":309,"tags":460,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fready-to-ship-not-ready-to-release","Ready to Ship, Not Ready to Release","Most teams hide a delay between saying done and being able to ship. Being ready to release at any moment, and why our deploy ships the commit that was tested.","2026-07-09T10:02:00.000Z","\u002Fimages\u002Fblog\u002Fready-to-ship-hero.jpg",[330,377,315],{"path":462,"title":463,"description":464,"date":465,"author":6,"image":466,"readingMinutes":326,"category":327,"project":309,"tags":467,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Frebase-or-merge-is-the-wrong-argument","Rebase or Merge Is the Wrong Argument","Both commands reconcile the same commits and hit the same conflicts. What differs is when you resolve them. The loud argument hides a quiet one: long branches.","2026-07-09T09:30:00.000Z","\u002Fimages\u002Fblog\u002Frebase-or-merge-hero.jpg",[329,330,331],{"path":469,"title":470,"description":471,"date":472,"author":6,"image":473,"readingMinutes":342,"category":438,"project":309,"tags":474,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Freconstructing-the-past","Reconstructing the Past","Connect a repository with a year of history and a trend curve reaches back months. How a system shows a past it never saw, recomputed from one source of truth.","2026-07-07T21:26:35.000Z","\u002Fimages\u002Fblog\u002Freconstructing-the-past-hero.jpg",[452,475,476],"data","onboarding",{"path":478,"title":479,"description":480,"date":481,"author":6,"image":312,"readingMinutes":326,"category":357,"project":439,"tags":482,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fretiring-a-board","Retiring a Board: User Testing a CLI Before You Ship It","An afternoon of user testing against a real Linear board, and the four findings a green test suite could never have written.","2026-08-01T10:20:00.000Z",[483,360,442,484],"user-testing","linear",{"path":486,"title":487,"description":488,"date":489,"author":6,"image":490,"readingMinutes":407,"category":357,"project":309,"tags":491,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fretrofitting-testing-without-mocks","Retrofitting Testing Without Mocks","The server had 37 vi.mock calls and no test that touched the database. Five steps to remove every mock with Nullables: real logic, real data, sociable tests.","2026-04-17T12:30:37.000Z","\u002Fimages\u002Fblog\u002Fretrofitting-without-mocks-hero.jpg",[361,360,492],"mocks",{"path":494,"title":495,"description":496,"date":497,"author":6,"image":498,"readingMinutes":369,"category":300,"project":439,"tags":499,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Frun-it-by-hand-until-it-is-boring","Run It by Hand Until It Is Boring","Releasing EkoLite was a document before it was a command, and stayed one until running it by hand had nothing left to teach us. Why we automate operations last.","2026-07-21T17:00:00.000Z","\u002Fimages\u002Fblog\u002Frun-it-by-hand-hero.jpg",[500,501,502,444],"automation","releasing","process",{"path":282,"title":504,"description":505,"date":472,"author":6,"image":506,"readingMinutes":342,"category":300,"project":309,"tags":507,"draft":303,"series":312,"seriesOrder":312},"Safe to Run Twice","Connect the same repository twice, or let a webhook redeliver an event. Idempotency in two halves: writes through a natural key, side effects behind a ledger.","\u002Fimages\u002Fblog\u002Fsafe-to-run-twice-hero.jpg",[315,316,508],"webhooks",{"path":510,"title":511,"description":512,"date":513,"author":6,"image":312,"readingMinutes":326,"category":300,"project":309,"tags":514,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fsource-or-execute","Source or Execute","Our dojo laptops are provisioned by one bash script of nearly twelve hundred lines. Splitting it into modules is two decisions, not one, and we chose a hybrid.","2026-08-01T09:00:00.000Z",[515,516,441,315],"shell","provisioning",{"path":518,"title":519,"description":520,"date":312,"author":521,"image":522,"readingMinutes":523,"category":438,"project":524,"tags":312,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fstage-then-transform-why-your-crawler-should-never-touch-production-tables","Stage, Then Transform: Crawlers Stay Out of Production","Where data from an external source lands first decides whether the pipeline still works in six months. The staging pattern, shown with a crawler we build now.","EkoHacks","\u002Fimages\u002Fblog\u002Fstage-then-transform-hero.jpg",1,"Propi",{"path":526,"title":527,"description":528,"date":529,"author":6,"image":312,"readingMinutes":310,"category":357,"project":337,"tags":530,"draft":303,"series":341,"seriesOrder":407},"\u002Fblog\u002Ftelling-the-machine-what-the-page-is","Telling the Machine What the Page Is","Structured data on every post, and a test that runs after each build and fails it when a page lacks what a crawler needs. First run: forty nine problems.","2026-08-22T23:30:00.000Z",[339,361,340],{"path":532,"title":533,"description":534,"date":535,"author":6,"image":536,"readingMinutes":291,"category":357,"project":312,"tags":312,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Ftest-driven-development-is-not-about-tests","Test Driven Development Is Not About Tests","TDD is widely misunderstood. It is not a testing strategy. It is a design tool that produces simpler, more focused code by making you think before you type.","2026-02-01T09:00:00.000Z","\u002Fimages\u002Fblog\u002Ftdd-not-about-tests-hero.jpg",{"path":538,"title":539,"description":540,"date":541,"author":6,"image":542,"readingMinutes":407,"category":357,"project":439,"tags":543,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Ftesting-a-release-without-releasing","Testing a Release Without Releasing","How to test watch CI until green, then merge, hundreds of times a day with no CI in sight. Nullables, output trackers, and a fake that copies GitHub shapes.","2026-07-21T17:10:00.000Z","\u002Fimages\u002Fblog\u002Ftesting-a-release-without-releasing-hero.jpg",[360,544,545,444],"testing-without-mocks","james-shore",{"path":547,"title":548,"description":549,"date":550,"author":6,"image":312,"readingMinutes":326,"category":357,"project":309,"tags":551,"draft":303,"series":553,"seriesOrder":523},"\u002Fblog\u002Fthe-best-coverage-number-in-the-room","The Best Coverage Number in the Room","Same commit, same spec, same test count. The mocked suite ran 5.6 times faster, covered 3.5 fewer points of real code, and posted the best branch coverage.","2026-08-27T12:00:00.000Z",[361,360,492,552],"coverage","What Mocks Cost",{"path":555,"title":556,"description":557,"date":558,"author":6,"image":559,"readingMinutes":407,"category":438,"project":358,"tags":560,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-bug-that-resolved-successfully","The Bug That Resolved Successfully","A bug that threw no error and dropped every document because the client guessed a collection from a name. The fix was one honest field on the ready message.","2026-07-05T10:16:16.000Z","\u002Fimages\u002Fblog\u002Fbug-resolved-successfully-hero.jpg",[561,562,361,563],"protocol","pubsub","tdd",{"path":565,"title":566,"description":567,"date":568,"author":6,"image":569,"readingMinutes":326,"category":357,"project":358,"tags":570,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-clock-that-learned-to-walk","The Clock That Learned to Walk","A stub clock that teleported to the end of the window instead of passing through time, so a timer set inside another never fired. A real clock model fixed it.","2026-07-07T22:44:42.000Z","\u002Fimages\u002Fblog\u002Fthe-clock-that-learned-to-walk-hero.jpg",[360,361,571],"refactoring",{"path":573,"title":574,"description":575,"date":576,"author":6,"image":577,"readingMinutes":407,"category":327,"project":309,"tags":578,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-conflict-nobody-wrote","The Conflict Nobody Wrote","A pull request conflicted in a file neither author had typed into. A generated document recorded test line numbers, both branches shifted them, git merges text.","2026-07-09T09:14:00.000Z","\u002Fimages\u002Fblog\u002Fthe-conflict-nobody-wrote-hero.jpg",[329,330,331],{"path":580,"title":581,"description":582,"date":583,"author":6,"image":584,"readingMinutes":585,"category":300,"project":309,"tags":586,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-container-that-wasnt-there","The Container That Wasn't There","Production served HTML where the browser asked for JavaScript, and nothing in our code had changed. A week debugging a platform we cannot see inside.","2026-07-17T14:30:00.000Z","\u002Fimages\u002Fblog\u002Fcontainer-that-wasnt-there-hero.jpg",8,[315,587,588,589],"debugging","european-tech","community",{"path":591,"title":592,"description":593,"date":594,"author":6,"image":595,"readingMinutes":326,"category":438,"project":358,"tags":596,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-design-we-never-drew","The Design We Never Drew","How the upload path in EkoLite arrived rather than being drawn, and the two habits, test first and integrate always, that turn an emerging shape into a design.","2026-07-20T10:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-design-we-never-drew-hero.jpg",[452,563,597],"continuous integration",{"path":599,"title":600,"description":601,"date":602,"author":6,"image":603,"readingMinutes":369,"category":327,"project":312,"tags":312,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-dojo-way-deliberate-practice-for-software-developers","The Dojo Way: Deliberate Practice for Software Developers","Most developers learn on the job, and the job rarely teaches the fundamentals. The Dojo borrows from martial arts to make a space for deliberate practice.","2026-01-22T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-dojo-way-hero.jpg",{"path":605,"title":606,"description":607,"date":608,"author":6,"image":609,"readingMinutes":310,"category":327,"project":309,"tags":610,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-done-column-is-a-syllabus","The Done Column Is a Syllabus","We asked Linear for every story our team has ever finished, all 187 of them, and read the column end to end. It turned out to be the course we teach.","2026-07-31T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fdone-column-syllabus-hero.jpg",[611,612,613,484],"graphql","stories","xp",{"path":615,"title":616,"description":617,"date":618,"author":6,"image":619,"readingMinutes":407,"category":357,"project":439,"tags":620,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-failing-test-is-the-review","The Failing Test Is the Review","Every behaviour in the CLI landed as two commits: a red test, then the code that makes it green. Reviewing intent before implementation, and why red never bent.","2026-07-21T17:05:00.000Z","\u002Fimages\u002Fblog\u002Fthe-failing-test-is-the-review-hero.jpg",[563,621,622,444],"red-first","code-review",{"path":624,"title":625,"description":626,"date":627,"author":6,"image":628,"readingMinutes":407,"category":300,"project":439,"tags":629,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-first-real-run-said-no","The First Real Run Said No","The tool's first real outing ended with FAIL and a stopped rail, and we count it as a success. A naive sounding question then uncovered a quiet failure mode.","2026-07-21T17:25:00.000Z","\u002Fimages\u002Fblog\u002Fthe-first-real-run-said-no-hero.jpg",[501,630,329,444],"preflight",{"path":632,"title":633,"description":634,"date":635,"author":6,"image":636,"readingMinutes":407,"category":300,"project":358,"tags":637,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-five-second-goodbye","The Five Second Goodbye","A shutdown that arms its own deadline so a hung close cannot strand the process, with the policy lifted out of the boot shell so every decision can be tested.","2026-07-05T12:03:00.000Z","\u002Fimages\u002Fblog\u002Fthe-five-second-goodbye-hero.jpg",[638,360,361],"shutdown",{"path":640,"title":641,"description":642,"date":643,"author":6,"image":644,"readingMinutes":326,"category":300,"project":358,"tags":645,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-framework-in-the-description-field","The Framework in the Description Field","Our package called itself a framework, but it could not be installed. The fix was to stop trusting the build and become the stranger who installs the tarball.","2026-07-08T17:28:39.000Z","\u002Fimages\u002Fblog\u002Fframework-description-field-hero.jpg",[646,361,331],"packaging",{"path":648,"title":649,"description":650,"date":651,"author":6,"image":652,"readingMinutes":369,"category":438,"project":439,"tags":653,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-gate-always-asks","The Gate Always Asks","A release automates things you cannot take back, so which of them may a machine do without asking a human? Pauses, and a yes flag that opens no door.","2026-07-21T17:15:00.000Z","\u002Fimages\u002Fblog\u002Fthe-gate-always-asks-hero.jpg",[500,654,452,444],"human-in-the-loop",{"path":287,"title":656,"description":657,"date":658,"author":6,"image":659,"readingMinutes":326,"category":300,"project":309,"tags":660,"draft":303,"series":312,"seriesOrder":312},"The Gate Went Red, and It Was Right","We added the missing check on our API specification and it failed on its first run, because the file was never a function of the source. A clean machine saw it.","2026-07-09T10:10:00.000Z","\u002Fimages\u002Fblog\u002Fgate-went-red-hero.jpg",[330,315,331],{"path":662,"title":663,"description":664,"date":665,"author":6,"image":666,"readingMinutes":326,"category":300,"project":358,"tags":667,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-knock-and-the-axe","The Knock and the Axe","SIGTERM is a request your process can hear and SIGKILL is removal it never sees, and the fixed window between them is exactly where graceful shutdown lives.","2026-07-20T12:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-knock-and-the-axe-hero.jpg",[638,315,668],"signals",{"path":670,"title":671,"description":672,"date":673,"author":6,"image":674,"readingMinutes":585,"category":327,"project":358,"tags":675,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-line-nothing-could-see","The Line Nothing Could See","Reviewing the reasoning under a refactor, not only the diff. Ask of every line what can observe it, and back each review claim with a test the author can run.","2026-07-07T22:43:00.000Z","\u002Fimages\u002Fblog\u002Fthe-line-nothing-could-see-hero.jpg",[622,571,676,361],"teaching",{"path":678,"title":679,"description":680,"date":681,"author":6,"image":682,"readingMinutes":326,"category":438,"project":358,"tags":683,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-long-way-round","The Long Way Round","The count a method computes reaches the screen not through its return value but by being written into a collection a client subscribes to, EkoLite's channel.","2026-07-20T11:05:00.000Z","\u002Fimages\u002Fblog\u002Fthe-long-way-round-hero.jpg",[452,562,360],{"path":685,"title":686,"description":687,"date":688,"author":6,"image":312,"readingMinutes":310,"category":300,"project":337,"tags":689,"draft":303,"series":341,"seriesOrder":523},"\u002Fblog\u002Fthe-page-that-existed-only-when-someone-linked-to-it","The Page That Existed Only When Someone Linked to It","Our blog vanished in production while passing every local check. How we found the cause, fixed it twice, and how the fix gave every page a twin.","2026-08-22T16:00:00.000Z",[339,315,340],{"path":691,"title":692,"description":693,"date":694,"author":6,"image":695,"readingMinutes":585,"category":300,"project":309,"tags":696,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-previews-that-served-production","The Previews That Served Production","Two weeks after the container that was not there, the same impossible error came back. The orphans were our own pull request previews. One diagnosis was wrong.","2026-07-31T17:00:00.000Z","\u002Fimages\u002Fblog\u002Fpreviews-served-production-hero.jpg",[315,587,588,589],{"path":698,"title":699,"description":700,"date":701,"author":6,"image":702,"readingMinutes":369,"category":327,"project":439,"tags":703,"draft":307,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-release-is-the-acceptance-test","The Release Is the Acceptance Test","Not a green suite, not a good demo: a real version of a real package, live on npm, shipped by the tool. What red first tests proved, and what only reality did.","2026-07-21T17:35:00.000Z","\u002Fimages\u002Fblog\u002Frelease-acceptance-test-hero.jpg",[501,704,705,444],"dogfooding","acceptance-testing",{"path":707,"title":708,"description":709,"date":710,"author":6,"image":711,"readingMinutes":310,"category":357,"project":358,"tags":712,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-script-that-never-ran","The Script That Never Ran","Joining two abilities EkoLite already had, a method that runs and a runner that shells out, and proving the join with a nulled runner before any script exists.","2026-07-20T11:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-script-that-never-ran-hero.jpg",[361,360,713],"scriptrunner",{"path":715,"title":716,"description":717,"date":718,"author":6,"image":312,"readingMinutes":585,"category":300,"project":337,"tags":719,"draft":303,"series":341,"seriesOrder":326},"\u002Fblog\u002Fthe-small-things-the-crawler-notices","The Small Things the Crawler Notices","Speed is the part of findability you can measure on a laptop. Fonts and images we shipped without looking, an error page we did not have, and the numbers after.","2026-08-23T10:00:00.000Z",[339,315,720],"performance",{"path":722,"title":723,"description":724,"date":725,"author":6,"image":726,"readingMinutes":310,"category":357,"project":358,"tags":727,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-test-that-could-not-fail","The Test That Could Not Fail","A green test that stayed green when we deleted the behaviour it was named after, and the single blunt question that exposed it.","2026-07-20T09:00:00.000Z","\u002Fimages\u002Fblog\u002Ftest-that-could-not-fail-hero.jpg",[361,360,362],{"path":729,"title":730,"description":731,"date":732,"author":521,"image":733,"readingMinutes":326,"category":327,"project":734,"tags":735,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-website-that-told-the-same-story-twice","The Website That Told the Same Story Twice","Our home and story pages felt like one story across two screens. Rather than argue by taste, we modelled our words as data and asked a database one question.","2026-06-06","\u002Fimages\u002Fblog\u002Fwebsite-same-story-twice-hero.jpg","The Website",[331,736,475,676],"content",{"path":738,"title":739,"description":740,"date":558,"author":6,"image":741,"readingMinutes":407,"category":357,"project":358,"tags":742,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-words-we-work-in","The Words We Work In","The vocabulary we test with, Nullable, embedded stub, stub object, output tracking, each grounded in real code so a review points at a line, never at taste.","\u002Fimages\u002Fblog\u002Fthe-words-we-work-in-hero.jpg",[360,361,331,676],{"path":744,"title":745,"description":746,"date":732,"author":521,"image":747,"readingMinutes":369,"category":327,"project":734,"tags":748,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fthe-workflow-is-the-lesson","The Workflow Is the Lesson","We are moving our content out of a cloud CMS and into git. Why a school that teaches a keyboard first way of working should practise it in the open, and how.","\u002Fimages\u002Fblog\u002Fworkflow-lesson-hero.png",[749,329,676,750],"workflow","accessibility",{"path":752,"title":753,"description":754,"date":755,"author":6,"image":312,"readingMinutes":326,"category":357,"project":309,"tags":756,"draft":303,"series":553,"seriesOrder":291},"\u002Fblog\u002Ftwenty-six-more-tests-four-fewer-behaviours","Twenty Six More Tests, Four Fewer Behaviours","Removing the mocks grew the suite from 44 tests to 70 and quietly deleted four behaviours, every one of them a failure path. Test count is not coverage.","2026-08-28T09:00:00.000Z",[361,360,492,552],{"path":308,"title":5,"description":302,"date":301,"author":6,"image":305,"readingMinutes":310,"category":300,"project":309,"tags":758,"draft":303,"series":312,"seriesOrder":312},[315,316,317],{"path":760,"title":761,"description":762,"date":763,"author":6,"image":312,"readingMinutes":310,"category":357,"project":309,"tags":764,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Ftwo-hundred-and-fourteen-undecided","Two Hundred and Fourteen Undecided","An accessibility scanner found three faults on the dojo, we fixed them, and the next run came back clean. The clean run was the dangerous one.","2026-08-22T12:00:00.000Z",[750,361,452,331],{"path":766,"title":767,"description":768,"date":769,"author":6,"image":770,"readingMinutes":342,"category":438,"project":309,"tags":771,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Ftwo-validators-one-gate","Two Validators, One Gate","Validating one request body twice, with a Fastify schema and again with Zod, is not double safety. The second gate is unreachable. One validator; Zod for types.","2026-07-07T22:06:46.000Z","\u002Fimages\u002Fblog\u002Ftwo-validators-one-gate-hero.jpg",[452,772,773],"validation","typescript",{"path":775,"title":776,"description":777,"date":778,"author":6,"image":779,"readingMinutes":326,"category":300,"project":309,"tags":780,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fwe-are-all-doing-asynchronous-integration","We Are All Doing Asynchronous Integration","A continuous integration server is not continuous integration. The forge tests your work after you have moved on. Our build takes three minutes; no excuse.","2026-07-09T09:46:00.000Z","\u002Fimages\u002Fblog\u002Fasynchronous-integration-hero.jpg",[330,315,377],{"path":782,"title":783,"description":784,"date":785,"author":6,"image":312,"readingMinutes":310,"category":300,"project":337,"tags":786,"draft":303,"series":341,"seriesOrder":310},"\u002Fblog\u002Fwe-promised-not-to-watch-then-we-needed-to-see","We Promised Not to Watch. Then We Needed to See.","Our privacy policy said no analytics, and it was true. Adding a page counter meant reopening that promise in public: what we chose, what we refused, one commit.","2026-08-24T10:00:00.000Z",[349,315,787],"analytics",{"path":789,"title":790,"description":791,"date":792,"author":6,"image":793,"readingMinutes":326,"category":300,"project":439,"tags":794,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fwhat-one-real-release-taught-us","What One Real Release Taught Us in an Afternoon","Two crashes and one near miss within minutes of touching the real GitHub, because we had refused to fake it. The race we never met, the merge that never came.","2026-07-21T17:30:00.000Z","\u002Fimages\u002Fblog\u002Fone-real-release-hero.jpg",[501,795,360,444],"failure-modes",{"path":797,"title":798,"description":799,"date":800,"author":6,"image":312,"readingMinutes":326,"category":300,"project":337,"tags":801,"draft":303,"series":341,"seriesOrder":369},"\u002Fblog\u002Fwhat-the-link-looks-like-before-anyone-clicks","What the Link Looks Like Before Anyone Clicks","A link to any page on our site showed up as a bare rectangle of text. The share image is part of the page, and the build now puts one on every route.","2026-08-22T22:00:00.000Z",[339,315,340],{"path":803,"title":804,"description":805,"date":806,"author":6,"image":312,"readingMinutes":326,"category":357,"project":309,"tags":807,"draft":303,"series":553,"seriesOrder":342},"\u002Fblog\u002Fwhat-the-nullable-gave-back","What the Nullable Gave Back","One file, seven behaviours held fixed, the database swapped for a Nullable: about 180 times less time inside the tests, and coverage flat to two decimals.","2026-08-29T09:00:00.000Z",[361,360,492,552],{"path":809,"title":810,"description":811,"date":812,"author":6,"image":312,"readingMinutes":310,"category":300,"project":337,"tags":813,"draft":303,"series":341,"seriesOrder":585},"\u002Fblog\u002Fwhat-we-count-and-what-we-refuse-to-know","What We Count, and What We Refuse to Know","Every system that holds anything about a visitor, in one table in the privacy policy. Writing it found a form that no longer existed and one that wrote twice.","2026-08-23T14:00:00.000Z",[349,315,787],{"path":815,"title":816,"description":817,"date":818,"author":6,"image":819,"readingMinutes":369,"category":438,"project":312,"tags":312,"draft":303,"series":312,"seriesOrder":312},"\u002Fblog\u002Fwhy-good-software-starts-with-the-right-questions","Why Good Software Starts with the Right Questions","Before writing a line of code, the most impactful thing a team can do is ask better questions. Discovery is not a phase to rush. It is the foundation.","2026-01-15T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fright-questions-hero.jpg",1787861097093]