[{"data":1,"prerenderedAt":8560},["ShallowReactive",2],{"post-\u002Fblog\u002Fthe-gate-went-red-and-it-was-right":3,"blog-all":375},{"id":4,"title":5,"author":6,"body":7,"category":359,"date":360,"description":361,"draft":362,"extension":363,"image":364,"meta":365,"navigation":178,"path":366,"project":367,"readingMinutes":97,"seo":368,"stem":369,"tags":370,"__hash__":374},"blog\u002Fblog\u002Fthe-gate-went-red-and-it-was-right.md","The Gate Went Red, and It Was Right","EkoHacks Team",{"type":8,"value":9,"toc":351},"minimark",[10,19,22,27,30,107,110,113,117,125,128,131,205,208,212,215,218,221,232,236,239,242,245,249,252,294,297,304,318,321,325,328,331,334,337,347],[11,12,13,18],"p",{},[14,15,17],"a",{"href":16},"\u002Fblog\u002Fnever-break-the-build","The previous post"," ended on a promise. We had found two holes in our pipeline: a documentation gate that could report a failure but never cause one, and, worse, no check whatsoever on the API specification that our web client is typed from. The first fix had merged. The spec check, we said, was following behind it.",[11,20,21],{},"It followed. It went red on its first run. And the reason it went red is the most useful thing we have learned this week.",[23,24,26],"h2",{"id":25},"the-check","The check",[11,28,29],{},"The shape is unremarkable, and that is rather the point. Regenerate the artefact from its source, and fail if the result differs from what is committed:",[31,32,37],"pre",{"className":33,"code":34,"language":35,"meta":36,"style":36},"language-yaml shiki shiki-themes github-light github-dark","- name: Check API spec and web types are current\n  id: api_types\n  run: |\n    npm run api:types\n    git diff --exit-code -- web\u002Fsrc\u002Flib\u002Fapi\u002Fopenapi.json web\u002Fsrc\u002Flib\u002Fapi\u002Fgenerated.d.ts\n  continue-on-error: true\n","yaml","",[38,39,40,60,71,83,89,95],"code",{"__ignoreMap":36},[41,42,45,49,53,56],"span",{"class":43,"line":44},"line",1,[41,46,48],{"class":47},"sVt8B","- ",[41,50,52],{"class":51},"s9eBZ","name",[41,54,55],{"class":47},": ",[41,57,59],{"class":58},"sZZnC","Check API spec and web types are current\n",[41,61,63,66,68],{"class":43,"line":62},2,[41,64,65],{"class":51},"  id",[41,67,55],{"class":47},[41,69,70],{"class":58},"api_types\n",[41,72,74,77,79],{"class":43,"line":73},3,[41,75,76],{"class":51},"  run",[41,78,55],{"class":47},[41,80,82],{"class":81},"szBVR","|\n",[41,84,86],{"class":43,"line":85},4,[41,87,88],{"class":58},"    npm run api:types\n",[41,90,92],{"class":43,"line":91},5,[41,93,94],{"class":58},"    git diff --exit-code -- web\u002Fsrc\u002Flib\u002Fapi\u002Fopenapi.json web\u002Fsrc\u002Flib\u002Fapi\u002Fgenerated.d.ts\n",[41,96,98,101,103],{"class":43,"line":97},6,[41,99,100],{"class":51},"  continue-on-error",[41,102,55],{"class":47},[41,104,106],{"class":105},"sj4cs","true\n",[11,108,109],{},"with the matching failure step at the bottom of the file, where this series has now twice insisted it belongs.",[11,111,112],{},"We had tested it before pushing, in both directions, which we now do as a matter of course. On a clean tree it passes. Rename a route's summary without regenerating, and it fails and names the two files that drifted. We were, in the private way one is about a small piece of infrastructure, pleased with it.",[23,114,116],{"id":115},"the-first-run","The first run",[31,118,123],{"className":119,"code":121,"language":122},[120],"language-text","Run npm run api:types\n  GITHUB_CLIENT_ID\u002FSECRET not set; GitHub login is disabled\n  Wrote OpenAPI spec to ...\u002Fweb\u002Fsrc\u002Flib\u002Fapi\u002Fopenapi.json\n\ndiff --git a\u002Fweb\u002Fsrc\u002Flib\u002Fapi\u002Fgenerated.d.ts\n-    \"\u002Fapi\u002Fv1\u002Fauth\u002Fgithub\": {\n","text",[38,124,121],{"__ignoreMap":36},[11,126,127],{},"Read the two halves of that together. The pipeline, generating the specification from the source, produced one that does not contain the GitHub login route. The committed specification does contain it. So the check did what it was built to do and reported a difference.",[11,129,130],{},"Nobody had forgotten anything. Here is the code the warning comes from:",[31,132,136],{"className":133,"code":134,"language":135,"meta":36,"style":36},"language-ts shiki shiki-themes github-light github-dark","const clientId = process.env.GITHUB_CLIENT_ID;\nconst clientSecret = process.env.GITHUB_CLIENT_SECRET;\n\nif (clientId && clientSecret) {\n  \u002F\u002F register \u002Fapi\u002Fv1\u002Fauth\u002Fgithub and its callback\n}\n","ts",[38,137,138,158,174,180,194,200],{"__ignoreMap":36},[41,139,140,143,146,149,152,155],{"class":43,"line":44},[41,141,142],{"class":81},"const",[41,144,145],{"class":105}," clientId",[41,147,148],{"class":81}," =",[41,150,151],{"class":47}," process.env.",[41,153,154],{"class":105},"GITHUB_CLIENT_ID",[41,156,157],{"class":47},";\n",[41,159,160,162,165,167,169,172],{"class":43,"line":62},[41,161,142],{"class":81},[41,163,164],{"class":105}," clientSecret",[41,166,148],{"class":81},[41,168,151],{"class":47},[41,170,171],{"class":105},"GITHUB_CLIENT_SECRET",[41,173,157],{"class":47},[41,175,176],{"class":43,"line":73},[41,177,179],{"emptyLinePlaceholder":178},true,"\n",[41,181,182,185,188,191],{"class":43,"line":85},[41,183,184],{"class":81},"if",[41,186,187],{"class":47}," (clientId ",[41,189,190],{"class":81},"&&",[41,192,193],{"class":47}," clientSecret) {\n",[41,195,196],{"class":43,"line":91},[41,197,199],{"class":198},"sJ8bj","  \u002F\u002F register \u002Fapi\u002Fv1\u002Fauth\u002Fgithub and its callback\n",[41,201,202],{"class":43,"line":97},[41,203,204],{"class":47},"}\n",[11,206,207],{},"The routes exist when the credentials do. The committed specification had been generated on a laptop that had them in its environment file. The pipeline has no environment file at all, so the pipeline generated a smaller, differently shaped, and entirely correct specification of the server as the pipeline is able to run it.",[23,209,211],{"id":210},"what-that-actually-means","What that actually means",[11,213,214],{},"The specification was never a function of the source code. It was a function of the source code and of whose machine last ran the generator.",[11,216,217],{},"Sit with the consequence. Two engineers on this team, one with GitHub credentials in their environment file and one without, regenerate the file after an unrelated schema change. They produce two different specifications. Each is correct on the machine that made it. Each is wrong everywhere else. Neither of them has done anything careless, and the committed file simply records whoever pushed most recently.",[11,219,220],{},"Now notice where that defect was sitting. It was inside the subject of the gate we had just built. The gate's entire purpose is to detect drift between a generated artefact and the source it is derived from. Its subject was not determined by that source. A drift detector pointed at a nondeterministic artefact does not detect drift. It detects the difference between two environments, and it will report that difference forever, on every run, for reasons that have nothing to do with the thing it was built to protect.",[11,222,223,224,227,228,231],{},"That is how gates die. Not by being removed, but by becoming noisy for a reason nobody can fix in a hurry, until somebody adds ",[38,225,226],{},"continue-on-error"," and moves on, and then the check is theatre and we are back where ",[14,229,230],{"href":16},"the last post"," started.",[23,233,235],{"id":234},"nothing-would-have-revealed-this","Nothing would have revealed this",[11,237,238],{},"The specification had been in the repository for months, regenerated by hand many times. Every one of those regenerations was performed on a developer's machine, and every one of them quietly reasserted that developer's environment as the truth. The file was always correct according to the only test anybody applied to it, which was that it looked right and the frontend compiled.",[11,240,241],{},"There is no code review that finds this. The diff, on the day the routes first appeared, looked exactly like a diff that should have appeared. There is no test that finds it, because the file agrees with itself. The only instrument that could ever have surfaced it is an attempt to regenerate the artefact somewhere with no environment, and then to insist, mechanically, that the two agree.",[11,243,244],{},"That is what a pipeline is. It is not a machine that runs your tests. It is the one participant in your project that has no laptop, no history, no accumulated configuration, and no memory of what used to work. It is the only honest reader of your repository, and this is why a repository that has never been built anywhere else is a repository full of assumptions nobody has met.",[23,246,248],{"id":247},"the-repair-and-its-smaller-cousin","The repair, and its smaller cousin",[11,250,251],{},"Two lines, before the server is built:",[31,253,255],{"className":133,"code":254,"language":135,"meta":36,"style":36},"\u002F\u002F The spec must describe the whole API surface, not the surface this particular\n\u002F\u002F environment happens to enable.\nprocess.env.GITHUB_CLIENT_ID ||= 'openapi-export-placeholder';\nprocess.env.GITHUB_CLIENT_SECRET ||= 'openapi-export-placeholder';\n",[38,256,257,262,267,282],{"__ignoreMap":36},[41,258,259],{"class":43,"line":44},[41,260,261],{"class":198},"\u002F\u002F The spec must describe the whole API surface, not the surface this particular\n",[41,263,264],{"class":43,"line":62},[41,265,266],{"class":198},"\u002F\u002F environment happens to enable.\n",[41,268,269,272,274,277,280],{"class":43,"line":73},[41,270,271],{"class":47},"process.env.",[41,273,154],{"class":105},[41,275,276],{"class":81}," ||=",[41,278,279],{"class":58}," 'openapi-export-placeholder'",[41,281,157],{"class":47},[41,283,284,286,288,290,292],{"class":43,"line":85},[41,285,271],{"class":47},[41,287,171],{"class":105},[41,289,276],{"class":81},[41,291,279],{"class":58},[41,293,157],{"class":47},[11,295,296],{},"No OAuth call is made. The routes are only described. Export with the real credentials or without them and the output is now identical, which is the property the gate needed all along and never had.",[11,298,299,300,303],{},"There was a second, quieter instance of the same thing in the very same step. Booting the server to read its specification constructs the database client, which refuses to exist without a connection string, although nothing in the export ever opens a connection. So the step needs a ",[38,301,302],{},"DATABASE_URL",", and the honest value for it is one that cannot possibly work:",[31,305,307],{"className":33,"code":306,"language":35,"meta":36,"style":36},"DATABASE_URL: postgresql:\u002F\u002Funused:unused@127.0.0.1:1\u002Fno_connection_is_made\n",[38,308,309],{"__ignoreMap":36},[41,310,311,313,315],{"class":43,"line":44},[41,312,302],{"class":51},[41,314,55],{"class":47},[41,316,317],{"class":58},"postgresql:\u002F\u002Funused:unused@127.0.0.1:1\u002Fno_connection_is_made\n",[11,319,320],{},"Port one, nothing listening. We checked that the export succeeds against it, which both proves that no connection is made and guarantees a loud failure on the day somebody makes the export depend on a real database without noticing. A fake value that would work if used is a trap. A fake value that cannot work is a test.",[23,322,324],{"id":323},"the-rule-underneath","The rule underneath",[11,326,327],{},"Before you can check that a generated file is current, the generator has to be a pure function of the thing it is generated from. This sounds obvious written down, and we did not know it about our own generator until a machine with no environment told us.",[11,329,330],{},"So when you write the check, spend a minute on what the generator reads besides its source. Environment variables. The clock. The network. The order the filesystem hands back a directory. The locale, which will one day sort your keys differently on somebody's machine and produce a diff nobody can explain. Each of those is a way for the artefact to depend on where it was made, and each of them turns a gate into an intermittent alarm.",[11,332,333],{},"We nearly shipped a gate like that. The thing that saved us was not care. It was that we let it fail, in the one place that could tell us the truth, and then read what it said instead of making it stop.",[335,336],"hr",{},[11,338,339,343,344],{},[340,341,342],"em",{},"The practice of continuous integration described in this series is set out in the continuous integration chapter of"," The Art of Agile Development ",[340,345,346],{},"by James Shore and Shane Warden.",[348,349,350],"style",{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":36,"searchDepth":62,"depth":62,"links":352},[353,354,355,356,357,358],{"id":25,"depth":62,"text":26},{"id":115,"depth":62,"text":116},{"id":210,"depth":62,"text":211},{"id":234,"depth":62,"text":235},{"id":247,"depth":62,"text":248},{"id":323,"depth":62,"text":324},"Shipping and Operations","2026-07-09T10:10:00.000Z","We added the missing check on our API specification, pushed it, and it failed on its first run. Not because anyone had forgotten to regenerate the file, but because the file was never a function of the source. It depended on whose environment last produced it, and only a machine with no environment could ever have noticed.",false,"md",null,{},"\u002Fblog\u002Fthe-gate-went-red-and-it-was-right","The Dojo",{"title":5,"description":361},"blog\u002Fthe-gate-went-red-and-it-was-right",[371,372,373],"continuous-integration","operations","craft","YcmsPR8Mu5t6rqMXT6W8EPuQ0qJdcfAxoXfY_gVoMFk",[376,535,775,976,1238,2260,2552,2685,3003,3297,3460,3568,3917,4150,4186,4214,4797,5649,5879,6042,6487,6878,7119,7534,7685,7881,7968,8177,8446],{"id":377,"title":378,"author":6,"body":379,"category":525,"date":526,"description":527,"draft":362,"extension":363,"image":364,"meta":528,"navigation":178,"path":529,"project":367,"readingMinutes":97,"seo":530,"stem":531,"tags":532,"__hash__":534},"blog\u002Fblog\u002Fa-branch-is-inventory.md","A Branch Is Inventory",{"type":8,"value":380,"toc":517},[381,384,387,390,394,397,400,403,407,410,417,423,426,429,433,436,439,450,457,460,464,467,470,473,480,483,487,490,493,496,500,503,506,509,511],[11,382,383],{},"We shipped a feature in three slices. Plan columns and a resolver first. Then a route letting an administrator set a cohort's plan. Then the quota itself, refusing a sixth member on a free cohort. Each slice was small, each was reviewable, each was independently useful. As a piece of work breakdown it was good.",[11,385,386],{},"The second and third slices were both cut from the first, and they were open at the same time. The second merged. The third, when its turn came, was conflicting.",[11,388,389],{},"Nothing was done carelessly. The slicing was sound and the branches were short by most standards, alive for about a day. And one day of divergence was enough to produce a conflict in a file neither author had opened. That is the number worth sitting with, because it tells you the tolerance is much tighter than it feels.",[23,391,393],{"id":392},"the-word-for-code-on-a-branch","The word for code on a branch",[11,395,396],{},"Manufacturing has a term for material that has been paid for, is not yet a product, and is sitting in a building: inventory. It looks like an asset on a naive reading, because money was spent to create it. It behaves like a liability. It occupies space, it must be tracked, it hides defects until someone opens the box, and it loses value while it waits.",[11,398,399],{},"Code on an unintegrated branch is inventory. It has been paid for. It is not yet part of the product. It is stored somewhere, it must be tracked, the defects in it are undiscovered, and every commit anyone else lands on the trunk devalues it slightly, because it is now written against a world that has moved.",[11,401,402],{},"A branch is not progress. Progress is what happens when the branch stops existing.",[23,404,406],{"id":405},"divergence-has-two-dimensions","Divergence has two dimensions",[11,408,409],{},"How much reconciliation you will eventually pay for is roughly the product of two things.",[11,411,412,416],{},[413,414,415],"strong",{},"Time."," The longer a branch lives, the more trunk commits it must eventually be squared with. This grows linearly and feels like it grows faster, because the commits stack up in unfamiliar parts of the codebase.",[11,418,419,422],{},[413,420,421],{},"Surface."," The more files a branch touches, and the more of them are files other people also touch, the higher the chance of overlap per unit of time.",[11,424,425],{},"Multiply them and you have your expected pain. What makes the second slice and third slice collide was a small time and a wide surface: a generated document that every change to the test suite rewrites is, by construction, a file with maximal overlap. Any two branches touching any tests at all will contend for it.",[11,427,428],{},"This suggests two levers, and teams almost always reach for the wrong one first. You can try to reduce surface, by dividing the codebase into territories, by rules about who edits what, by discouraging people from touching shared files. That is how you get a codebase nobody is allowed to improve. Or you can reduce time, which requires nothing of the code and everything of the habit.",[23,430,432],{"id":431},"integrate-before-it-is-finished","Integrate before it is finished",[11,434,435],{},"The objection to integrating every few hours is always the same, and it is a good objection. The work is not done. The quota is half implemented. Merging it into the trunk would put an incomplete feature in front of users.",[11,437,438],{},"This confuses two different kinds of done, and untangling them is the unlock.",[11,440,441,442,445,446,449],{},"Being ",[413,443,444],{},"functionally ready"," means the feature does what a user needs. Being ",[413,447,448],{},"technologically ready"," means the code compiles, the tests pass, and the trunk is in a state that could be built and shipped. You cannot integrate work that is not technologically ready. You can absolutely integrate work that is not functionally ready, and you should, several times a day.",[11,451,452,453,456],{},"The rule for whether a branch can go in is not \"is the story finished\". It is: ",[413,454,455],{},"does it build, and does the whole test suite pass?"," A half implemented quota, with the resolver written and tested and the route not yet calling it, meets that bar. Nothing is user visible. Nothing is broken. And the thousand lines of context it is written against are now the same thousand lines everybody else is working from.",[11,458,459],{},"Under test driven development you are never more than a few minutes away from a state that satisfies this, because that is what the red green cycle produces: a green bar, every few minutes, by design. The reason continuous integration and TDD are usually described together is not fashion. TDD is what makes integrating this often physically possible.",[23,461,463],{"id":462},"the-rule-that-sounds-absurd","The rule that sounds absurd",[11,465,466],{},"Many teams practising this agree that you must integrate before you go home. Not \"should\". Must. And they attach a consequence that reads, the first time you meet it, like a joke: if you cannot integrate, throw the day's work away and start again tomorrow.",[11,468,469],{},"Sit with it and it stops being a joke.",[11,471,472],{},"If you have been working test first, you were green a few minutes ago. If you now cannot reach a state that builds and passes, something has gone wrong that is larger than a typo, and the distance between you and safety is not the few minutes you think it is. The code you would be discarding cost you an afternoon. The debugging you are about to embark on, in a tangle you no longer understand, at the end of a day, will cost you more, and it will produce something you trust less.",[11,474,475,476,479],{},"There is a version of this that is easier to accept. Not \"throw away a day\", but: ",[413,477,478],{},"when you are badly stuck, revert to the last green commit and redo the work."," The second attempt is nearly always faster, because you now know the shape of the problem, and it is nearly always cleaner, because you are no longer carrying the three abandoned approaches you tried along the way.",[11,481,482],{},"The reason this rule can exist at all is the same reason the whole practice can exist: the thing you are reverting to is at most a few hours old.",[23,484,486],{"id":485},"say-it-out-loud-before-you-do-it","Say it out loud before you do it",[11,488,489],{},"One more piece of common sense that costs nothing. Collisions cluster around wide ranging changes. Renaming something used everywhere, moving a module, changing a signature that thirty files depend on.",[11,491,492],{},"When you are about to make a change like that, tell the team first, so they can integrate whatever they are holding before you land it. Then land yours quickly. The alternative is that four people each discover, separately, at different hours, that the thing they were building against has been renamed.",[11,494,495],{},"This is not a process. It is a sentence in a chat channel.",[23,497,499],{"id":498},"what-we-should-have-done","What we should have done",[11,501,502],{},"The third slice was cut from the first while the second was in flight. The moment the second merged, the third was working against a trunk that no longer existed, and nobody noticed, because there is nothing in the tooling that notices. Git will not tap you on the shoulder to say that the ground moved.",[11,504,505],{},"The correct move was not to resolve the conflict better at the end. It was to bring the trunk into the branch the moment the second slice landed, an hour's work at most, at a point where the only thing to reconcile was one generated file that needed regenerating anyway.",[11,507,508],{},"We did not, because integrating is something we do when we open a pull request, rather than something we do while we work. That habit has a name, and the next post is about why it is the weaker of the two available practices, and why almost every team using a forge has adopted it without ever choosing it.",[335,510],{},[11,512,513,343,515],{},[340,514,342],{},[340,516,346],{},{"title":36,"searchDepth":62,"depth":62,"links":518},[519,520,521,522,523,524],{"id":392,"depth":62,"text":393},{"id":405,"depth":62,"text":406},{"id":431,"depth":62,"text":432},{"id":462,"depth":62,"text":463},{"id":485,"depth":62,"text":486},{"id":498,"depth":62,"text":499},"Craft and Practice","2026-07-09T09:38:00.000Z","Unintegrated code is not progress, it is stock sitting in a warehouse, and it depreciates. Our quota branch was one merge behind the trunk and that was enough to produce a conflict. What it costs to leave work on a branch, and the strange rule about throwing it away.",{},"\u002Fblog\u002Fa-branch-is-inventory",{"title":378,"description":527},"blog\u002Fa-branch-is-inventory",[533,371,373],"git","xF9NtqI8ujrPIAAv5YMAt5W9AiCNKcoWM6xkA5werxM",{"id":536,"title":537,"author":6,"body":538,"category":525,"date":768,"description":769,"draft":362,"extension":363,"image":364,"meta":770,"navigation":178,"path":771,"project":364,"readingMinutes":85,"seo":772,"stem":773,"tags":364,"__hash__":774},"blog\u002Fblog\u002Fbuilding-engineering-teams-that-learn-together.md","Building Engineering Teams That Learn Together",{"type":8,"value":539,"toc":760},[540,543,546,551,554,557,564,568,571,580,583,590,597,604,607,614,617,621,624,627,630,642,645,649,652,655,658,666,669,673,676,683,686,689,696,699,706,709,716,719,726,729,733,736,750,757],[11,541,542],{},"There is a pattern we see in nearly every organisation we work with. The team has talented individuals. Smart people who care about their craft. But somehow, the sum is less than the parts. Decisions get stuck. Knowledge lives in one person's head. When that person goes on leave, everything slows down.",[11,544,545],{},"The issue is rarely about individual skill. It is about how the team learns, shares, and grows together.",[547,548,550],"h3",{"id":549},"the-problem-with-the-10x-developer-myth","The Problem with the 10x Developer Myth",[11,552,553],{},"The industry loves the idea of the exceptional individual who can turn a project around alone. These people exist, but building around them is fragile.",[11,555,556],{},"When one person holds all the knowledge, you haven’t built a team; you’ve built a dependency. And dependencies are risks.",[11,558,559,560,563],{},"The alternative is deliberate knowledge distribution: designing your ways of working so that no single departure can derail the project. This does ",[340,561,562],{},"not"," happen by accident.",[547,565,567],{"id":566},"what-a-learning-team-looks-like","What a Learning Team Looks Like",[11,569,570],{},"Learning teams have clear, observable behaviours:",[572,573,574],"ol",{},[575,576,577],"li",{},[413,578,579],{},"They ask questions openly.",[11,581,582],{},"There is no social cost to saying “I don’t know” or “can you explain that?” Pretending to understand is treated as more dangerous than admitting confusion.",[572,584,585],{},[575,586,587],{},[413,588,589],{},"They share context, not just code.",[11,591,592,593,596],{},"Work doesn’t end at “PR merged.” People explain ",[340,594,595],{},"why"," they chose an approach, what alternatives they considered, and which trade‑offs they accepted.",[572,598,599],{},[575,600,601],{},[413,602,603],{},"They rotate responsibilities.",[11,605,606],{},"Nobody is the permanent owner of a domain. People move between areas of the codebase, between frontend and backend, between features and infrastructure. It’s uncomfortable at first and invaluable over time.",[572,608,609],{},[575,610,611],{},[413,612,613],{},"They reflect regularly.",[11,615,616],{},"Not just in formal retros that can become performative, but in ongoing, honest conversations about what is and isn’t working, often informally, as part of the daily rhythm.",[547,618,620],{"id":619},"pair-programming-as-knowledge-transfer","Pair Programming as Knowledge Transfer",[11,622,623],{},"Pair programming is one of the most effective, and most resisted, tools for building a learning culture.",[11,625,626],{},"It feels slow. It feels like being watched. That discomfort is the point.",[11,628,629],{},"When two people work together:",[631,632,633,636,639],"ul",{},[575,634,635],{},"They must articulate their thinking.",[575,637,638],{},"They can’t hide behind habit or instinct.",[575,640,641],{},"They expose gaps in their own reasoning.",[11,643,644],{},"An hour of pairing can transfer understanding that would take weeks of documentation and months of solo work to develop.",[547,646,648],{"id":647},"psychological-safety-as-a-precondition","Psychological Safety as a Precondition",[11,650,651],{},"None of this works without psychological safety.",[11,653,654],{},"If people fear looking foolish, they won’t ask questions. If mistakes are punished, they’ll be hidden. If seniority decides whose ideas matter, junior people will stop contributing.",[11,656,657],{},"Psychological safety is not about being nice. It’s about:",[631,659,660,663],{},[575,661,662],{},"Valuing intellectual honesty over being right.",[575,664,665],{},"Treating changing your mind as a strength.",[11,667,668],{},"This starts with leadership. When the most senior people ask questions, admit uncertainty, and visibly change their minds in response to better arguments, everyone else gets permission to do the same.",[547,670,672],{"id":671},"practical-steps-to-build-a-learning-team","Practical Steps to Build a Learning Team",[11,674,675],{},"This is not a one‑off initiative; it’s a continuous practice. Some concrete moves:",[572,677,678],{},[575,679,680],{},[413,681,682],{},"Start each week with a learning goal.",[11,684,685],{},"Not just “what will we ship?” but “what will we understand better by Friday?”",[11,687,688],{},"Example: “This week we want to understand how the payment system handles retries.”",[572,690,691],{},[575,692,693],{},[413,694,695],{},"Run internal tech talks.",[11,697,698],{},"Short, informal, frequent. Anyone can present. The goal is practice explaining ideas and exposing the team to different perspectives, not polished performance.",[572,700,701],{},[575,702,703],{},[413,704,705],{},"Make code review a conversation.",[11,707,708],{},"Whenever possible, review synchronously. Walk through the code together, discuss alternatives, explore edge cases. Treat review as a learning moment, not a gate.",[572,710,711],{},[575,712,713],{},[413,714,715],{},"Celebrate questions, not just answers.",[11,717,718],{},"Call out questions that change direction or reveal gaps in understanding. Signal that curiosity is a first‑class contribution.",[572,720,721],{},[575,722,723],{},[413,724,725],{},"Document decisions, not just code.",[11,727,728],{},"Use lightweight decision records: what was decided, why, what alternatives were rejected, and links to relevant discussions. This builds institutional memory that survives team changes.",[547,730,732],{"id":731},"playing-the-long-game","Playing the Long Game",[11,734,735],{},"You won’t see a dramatic productivity spike in the first month. But over 6, 24 months, the compounding effect is significant:",[631,737,738,741,744,747],{},[575,739,740],{},"Faster onboarding and less knowledge loss when people leave.",[575,742,743],{},"Better, more resilient designs because more minds understand the system.",[575,745,746],{},"Teams that adapt quickly to change instead of freezing when a key person is unavailable.",[575,748,749],{},"A working environment that is simply more enjoyable to be part of.",[11,751,752,753,756],{},"The strongest engineering teams are not the ones with the most individually talented people. They are the ones that have built the ",[340,754,755],{},"capacity to grow together",".",[11,758,759],{},"That capacity, shared learning, shared context, shared ownership, is the most valuable asset a technology organisation can build.",{"title":36,"searchDepth":62,"depth":62,"links":761},[762,763,764,765,766,767],{"id":549,"depth":73,"text":550},{"id":566,"depth":73,"text":567},{"id":619,"depth":73,"text":620},{"id":647,"depth":73,"text":648},{"id":671,"depth":73,"text":672},{"id":731,"depth":73,"text":732},"2026-02-05T09:00:00.000Z","The strongest engineering teams are not the ones with the most talented individuals. They are the ones that have learned how to learn together.",{},"\u002Fblog\u002Fbuilding-engineering-teams-that-learn-together",{"title":537,"description":769},"blog\u002Fbuilding-engineering-teams-that-learn-together","ACrdh2CfDIFsnfh8_Ss1sfqRxAx58n6WFZpKgFbwxoA",{"id":776,"title":777,"author":6,"body":778,"category":359,"date":966,"description":967,"draft":362,"extension":363,"image":364,"meta":968,"navigation":178,"path":969,"project":367,"readingMinutes":85,"seo":970,"stem":971,"tags":972,"__hash__":975},"blog\u002Fblog\u002Fderived-state-recompute.md","The Fix Was Merged, but the Numbers Were Still Wrong",{"type":8,"value":779,"toc":960},[780,799,803,814,825,836,840,843,885,888,892,907,910,930,934,957],[11,781,782,783,786,787,790,791,794,795,798],{},"We fixed a real bug in how the dashboard scores developers: the Impact Score was double counting rework, First Attempt Success divided by the wrong denominator, and we added a new ",[38,784,785],{},"reworkedLines"," signal. The fix was reviewed and merged to ",[38,788,789],{},"main",". And the dashboards in production still showed the old, wrong numbers. Nothing was broken about the fix. The lesson lives in the gap between ",[413,792,793],{},"merged"," and ",[413,796,797],{},"live and correct",", and it has two halves.",[23,800,802],{"id":801},"half-one-derived-state-does-not-fix-itself","Half one: derived state does not fix itself",[11,804,805,806,809,810,813],{},"A developer snapshot is not a fact we store, it is a fact we ",[413,807,808],{},"compute",". Every snapshot is calculated from the pull requests underneath it: merge rate, rework, size, and the Impact Score on top. We then write the result into a ",[38,811,812],{},"DeveloperSnapshot"," row so the dashboard can read it back fast, without recomputing on every page load.",[11,815,816,817,820,821,824],{},"That storing is the catch. A snapshot row is a ",[413,818,819],{},"cache of a calculation",". When you change the calculation you change what ",[340,822,823],{},"new"," snapshots will say. You do not touch the rows already sitting in the table. They were written by the old formula and they keep the old numbers until something rewrites them.",[11,826,827,828,831,832,835],{},"So \"the formula is fixed in the code\" and \"the stored numbers are right\" are two different states of the world, and the first does not imply the second. Anything derived, whether a snapshot, a cached total, a denormalised count, or a search index, carries this property: change the recipe and you have to ",[413,829,830],{},"rebuild the thing the recipe made",". We have a script for exactly that, ",[38,833,834],{},"recompute-snapshots.ts",", which rebuilds every snapshot from its pull requests.",[23,837,839],{"id":838},"half-two-production-is-not-your-laptop","Half two: production is not your laptop",[11,841,842],{},"Knowing we needed a recompute, the naive plan is \"run the script against prod.\" Scoping it turned up three reasons that does not work, and each is a way production quietly differs from local:",[631,844,845,858,879],{},[575,846,847,850,851,853,854,857],{},[413,848,849],{},"The server was not even on the new build."," Our server is manual deploy, and its last deploy predated the merge. The new ",[38,852,785],{}," column does not exist in the production database until a deploy runs ",[38,855,856],{},"prisma migrate deploy",". Recompute first and there is no column to write into, and it would run the old code anyway.",[575,859,860,863,864,867,868,794,871,874,875,878],{},[413,861,862],{},"The script was not in the image."," The Dockerfile copied ",[38,865,866],{},"prisma",", ",[38,869,870],{},"tsconfig",[38,872,873],{},"src",", but not ",[38,876,877],{},"scripts\u002F",". The recompute script was simply not present in the container, so there was nothing to run.",[575,880,881,884],{},[413,882,883],{},"The database is internal only."," Production's DB is not reachable from a laptop; its connection string points at an internal host. Running the script locally would mean briefly opening external access and handling the DB password, exactly the sort of temporary hole you forget to close.",[11,886,887],{},"None of these is exotic. They are the ordinary difference between the machine you wrote the code on and the environment it actually runs in.",[23,889,891],{"id":890},"the-decision","The decision",[11,893,894,895,898,899,902,903,906],{},"Run the maintenance ",[413,896,897],{},"inside the platform, on boot, behind a flag",". The server already runs ",[38,900,901],{},"migrate deploy",", then seed, then start on every deploy. We added one step: when ",[38,904,905],{},"RECOMPUTE_SNAPSHOTS=1",", run the recompute after the migration and before the server starts. To ship the fix you set the flag, deploy once (which migrates and recomputes against the internal DB), then set the flag back.",[11,908,909],{},"Three properties made this the right shape:",[631,911,912,918,924],{},[575,913,914,917],{},[413,915,916],{},"It runs where the data lives."," No external access, no password on a laptop, no hole to close afterwards.",[575,919,920,923],{},[413,921,922],{},"It is idempotent."," The recompute rebuilds from source and the XP awards are award once, so running it twice changes nothing the second time. A leftover flag costs boot time, not correctness. Idempotence is what makes a data rebuild safe to run, and safe to run again when you are not sure the first one finished.",[575,925,926,929],{},[413,927,928],{},"It is repeatable."," The next metric change pulls the same lever. We did not build a one off, we built the mechanism we will use again.",[23,931,933],{"id":932},"the-lesson","The lesson",[631,935,936,942,951],{},[575,937,938,941],{},[413,939,940],{},"Derived data is a cache, and caches go stale on purpose."," When you change a formula, ask \"what did the old formula already write down, and who rebuilds it?\" If the answer is \"nobody,\" you have shipped half a fix.",[575,943,944,947,948,950],{},[413,945,946],{},"Trace the path from merged to live."," Merged to ",[38,949,789],{}," is not deployed; deployed is not migrated; migrated is not recomputed. Each arrow is a step that can be missing. Walk them before you call it done.",[575,952,953,956],{},[413,954,955],{},"Make maintenance safe and repeatable."," Run it where the data lives, make it idempotent, and put it behind a flag you can pull again, rather than a heroic one time script run by hand with the production password in your shell history.",[11,958,959],{},"The fix was the easy part. Getting it to change what a user actually sees, without leaving a door open behind you, is the craft.",{"title":36,"searchDepth":62,"depth":62,"links":961},[962,963,964,965],{"id":801,"depth":62,"text":802},{"id":838,"depth":62,"text":839},{"id":890,"depth":62,"text":891},{"id":932,"depth":62,"text":933},"2026-07-08T21:43:55.000Z","We fixed a scoring bug, reviewed it, merged it, and production still showed the old numbers. The lesson lives in the gap between merged and live and correct: derived data is a cache that goes stale, and maintenance belongs where the data lives.",{},"\u002Fblog\u002Fderived-state-recompute",{"title":777,"description":967},"blog\u002Fderived-state-recompute",[372,973,974],"deployment","idempotency","hTjzzyi1zYCHNUeYYEN-HInp_btmVzH2L2rmJpCzXSQ",{"id":977,"title":978,"author":979,"body":980,"category":359,"date":1230,"description":1231,"draft":362,"extension":363,"image":1232,"meta":1233,"navigation":178,"path":1234,"project":364,"readingMinutes":97,"seo":1235,"stem":1236,"tags":364,"__hash__":1237},"blog\u002Fblog\u002Fdockerising-nestjs-nuxt-monorepo-with-pnpm.md","Dockerising a NestJS and Nuxt Monorepo with pnpm","Ogochukwu Okpala",{"type":8,"value":981,"toc":1211},[982,985,988,1004,1008,1011,1014,1017,1021,1024,1039,1043,1046,1049,1053,1069,1072,1075,1079,1083,1086,1089,1093,1096,1099,1103,1106,1110,1113,1123,1135,1138,1145,1148,1154,1158,1161,1164,1167,1171,1174,1180,1186,1192,1198,1202,1205,1208],[11,983,984],{},"If you run a monorepo with a NestJS backend and a Nuxt frontend, you have probably wondered how to Dockerise it properly. Development should feel local and fast. Production should be lean and predictable. And the whole thing should work the same way on every machine, every time.",[11,986,987],{},"This post walks through a practical approach to achieving exactly that. No gatekeeping, no unnecessary complexity. Just a clear setup that separates development from production and keeps your team moving.",[11,989,990],{},[340,991,992,993,1003],{},"This article is adapted from ",[340,994,995],{},[14,996,1000],{"href":997,"rel":998},"https:\u002F\u002Fmedium.com\u002F@okpalaogochukwu76\u002Fhow-i-dockerized-a-nestjs-nuxt-monorepo-with-pnpm-without-losing-my-mind-572b9d215ddf",[999],"nofollow",[340,1001,1002],{},"the original post on Medium"," by Ogochukwu Okpala.",[23,1005,1007],{"id":1006},"docker-is-just-another-linux-machine","Docker Is Just Another Linux Machine",[11,1009,1010],{},"A Docker container is not magic. It is simply a fresh Linux machine with its own filesystem, its own users, and its own network. It knows nothing about your laptop. Every file it needs must be explicitly copied in. Every tool it uses must be explicitly installed.",[11,1012,1013],{},"Once you accept that mental model, most Docker questions answer themselves. \"Why do I need to copy this file?\" Because the container does not have it. \"Why is my app not reachable?\" Because the container's network is isolated from yours.",[11,1015,1016],{},"This mindset makes Docker far less intimidating and far more logical.",[23,1018,1020],{"id":1019},"the-monorepo-structure","The Monorepo Structure",[11,1022,1023],{},"The setup assumes a standard pnpm workspace monorepo. Your backend and frontend live as separate applications under a shared root, with a single lockfile and workspace configuration at the top level.",[11,1025,1026,1027,1030,1031,1034,1035,1038],{},"The key files at the root are your ",[38,1028,1029],{},"package.json",", your ",[38,1032,1033],{},"pnpm-workspace.yaml",", and your ",[38,1036,1037],{},"pnpm-lock.yaml",". Each app has its own dependencies, its own build process, and its own Dockerfile.",[23,1040,1042],{"id":1041},"corepack-and-pnpm-reproducible-installs","Corepack and pnpm: Reproducible Installs",[11,1044,1045],{},"Node ships with Corepack, which is essentially a package manager manager. Since the project uses pnpm, you enable Corepack in your Dockerfile and lock pnpm to a specific major version.",[11,1047,1048],{},"This single step means every developer, every CI pipeline, and every container uses the exact same version of pnpm. No more \"it works on my machine\" surprises caused by version drift. Builds become reproducible by default.",[23,1050,1052],{"id":1051},"multi-stage-dockerfiles","Multi Stage Dockerfiles",[11,1054,1055,1056,867,1059,867,1062,1065,1066,756],{},"Both the backend and frontend Dockerfiles follow the same multi stage pattern with four stages: ",[413,1057,1058],{},"base",[413,1060,1061],{},"dev",[413,1063,1064],{},"build",", and ",[413,1067,1068],{},"prod",[11,1070,1071],{},"This distinction matters more than most people realise. Development containers are built for humans. They include hot reloading, debugging tools, and full dependency trees. Production containers are built for machines. They contain only what is strictly needed to run the compiled application.",[11,1073,1074],{},"These two environments should never look the same. Mixing them leads to bloated production images and fragile development workflows.",[23,1076,1078],{"id":1077},"the-backend-nestjs","The Backend: NestJS",[547,1080,1082],{"id":1081},"development","Development",[11,1084,1085],{},"In development, the backend container installs all dependencies and runs NestJS in debug mode with hot reloading. File changes on your local machine sync into the container, so the development experience feels natural.",[11,1087,1088],{},"One important detail: the container creates a dedicated non root user. Running as root inside a container is a common shortcut, but it introduces security risks and can cause file permission issues that surface at the worst possible time. Creating a proper user from the start avoids these problems entirely.",[547,1090,1092],{"id":1091},"building-for-production","Building for Production",[11,1094,1095],{},"The build stage installs dependencies, copies source code, and compiles the application. Then it uses pnpm's deploy command to create a clean, self contained production bundle.",[11,1097,1098],{},"The deploy command is the key piece. It creates a standalone folder that contains only production dependencies and the compiled output. Think of it as generating a deployment artifact that has everything the application needs and nothing it does not.",[547,1100,1102],{"id":1101},"the-production-image","The Production Image",[11,1104,1105],{},"The production stage starts from a fresh Node image, copies in the deployment artifact, and runs the compiled application directly with Node. No source files. No development dependencies. No pnpm. Just the runtime and the code.",[23,1107,1109],{"id":1108},"the-frontend-nuxt","The Frontend: Nuxt",[547,1111,1082],{"id":1112},"development-1",[11,1114,1115,1116,1119,1120,756],{},"Nuxt development in Docker requires one critical configuration: binding the dev server to ",[38,1117,1118],{},"0.0.0.0"," instead of the default ",[38,1121,1122],{},"localhost",[11,1124,1125,1126,1128,1129,1131,1132,1134],{},"Inside a container, ",[38,1127,1122],{}," refers to the container itself, not your host machine. If the Nuxt dev server only listens on ",[38,1130,1122],{},", your browser cannot reach it. Binding to ",[38,1133,1118],{}," tells the server to accept connections from any network interface, which includes traffic from outside the container. Without this, you will see a blank page and wonder what went wrong.",[547,1136,1092],{"id":1137},"building-for-production-1",[11,1139,1140,1141,1144],{},"Nuxt compiles into a ",[38,1142,1143],{},".output"," folder that contains everything needed to run the application. The build stage installs dependencies, copies source code, runs the build, and then prunes away development dependencies.",[547,1146,1102],{"id":1147},"the-production-image-1",[11,1149,1150,1151,1153],{},"Nuxt's production image is remarkably clean. You copy the ",[38,1152,1143],{}," directory and any static assets into a fresh Node image and run the server entry point. No build tools, no package manager, no source code. Just Node serving the compiled application.",[23,1155,1157],{"id":1156},"docker-compose-bringing-it-together","Docker Compose: Bringing It Together",[11,1159,1160],{},"With both Dockerfiles in place, Docker Compose acts as the orchestrator. Using profiles, you can define separate configurations for development and production.",[11,1162,1163],{},"In development, both services run together with file watchers and hot reloading active. Changes to your source code reflect immediately. Changes to your dependency files trigger rebuilds. The experience mirrors local development, but inside containers that match your deployment environment.",[11,1165,1166],{},"In production, the same Compose file spins up clean, compiled containers with no watchers and no rebuild logic. Just the applications running as they would in a real deployment.",[23,1168,1170],{"id":1169},"what-this-gets-you","What This Gets You",[11,1172,1173],{},"This approach gives your team several things that compound over time.",[11,1175,1176,1179],{},[413,1177,1178],{},"Consistency."," Every environment, from a developer's laptop to CI to production, runs the same way. Environment specific bugs become rare.",[11,1181,1182,1185],{},[413,1183,1184],{},"Clean separation."," Development and production never share the same concerns. Your development workflow stays fast and ergonomic. Your production images stay small and secure.",[11,1187,1188,1191],{},[413,1189,1190],{},"Simplicity."," Despite involving multiple stages and two separate applications, the setup follows a predictable pattern. Once you understand the backend Dockerfile, the frontend Dockerfile feels familiar.",[11,1193,1194,1197],{},[413,1195,1196],{},"Ownership."," Your team controls the full deployment pipeline. There is no mystery about what runs in production or how it got there.",[23,1199,1201],{"id":1200},"final-thoughts","Final Thoughts",[11,1203,1204],{},"Dockerising a monorepo is not inherently difficult. It simply requires you to be deliberate about what each environment needs and disciplined about keeping them separate.",[11,1206,1207],{},"Docker is strict, not hard. pnpm is honest, not complicated. And monorepos work well when you structure them with intention.",[11,1209,1210],{},"Once everything fits together, the setup feels clean, predictable, and easy to reason about. That is the kind of foundation that serves a team well as a project grows.",{"title":36,"searchDepth":62,"depth":62,"links":1212},[1213,1214,1215,1216,1217,1222,1227,1228,1229],{"id":1006,"depth":62,"text":1007},{"id":1019,"depth":62,"text":1020},{"id":1041,"depth":62,"text":1042},{"id":1051,"depth":62,"text":1052},{"id":1077,"depth":62,"text":1078,"children":1218},[1219,1220,1221],{"id":1081,"depth":73,"text":1082},{"id":1091,"depth":73,"text":1092},{"id":1101,"depth":73,"text":1102},{"id":1108,"depth":62,"text":1109,"children":1223},[1224,1225,1226],{"id":1112,"depth":73,"text":1082},{"id":1137,"depth":73,"text":1092},{"id":1147,"depth":73,"text":1102},{"id":1156,"depth":62,"text":1157},{"id":1169,"depth":62,"text":1170},{"id":1200,"depth":62,"text":1201},"2026-02-09T09:00:00.000Z","A practical guide to Dockerising a NestJS and Nuxt monorepo using pnpm, with separate multi stage Dockerfiles for development and production and a single Docker Compose setup for consistent, reproducible environments.","\u002Fimages\u002Fblog\u002Fdocker-monorepo-hero.png",{},"\u002Fblog\u002Fdockerising-nestjs-nuxt-monorepo-with-pnpm",{"title":978,"description":1231},"blog\u002Fdockerising-nestjs-nuxt-monorepo-with-pnpm","8W91Aa6DCkNgVt02Oht5_zTuMj3HoJYSBQHq1WsJw1o",{"id":1239,"title":1240,"author":6,"body":1241,"category":525,"date":2251,"description":2252,"draft":362,"extension":363,"image":364,"meta":2253,"navigation":178,"path":2254,"project":367,"readingMinutes":85,"seo":2255,"stem":2256,"tags":2257,"__hash__":2259},"blog\u002Fblog\u002Ffrom-convention-to-plugin.md","From Convention to Plugin",{"type":8,"value":1242,"toc":2243},[1243,1246,1250,1253,1582,1595,1601,1605,1608,1900,1911,2020,2027,2031,2118,2121,2134,2138,2148,2154,2164,2176,2186,2190,2196,2204,2208,2211,2221,2240],[11,1244,1245],{},"This refactor replaces a five step CLAUDE.md convention with a Fastify plugin. The code got shorter, the failure mode got louder, and three files shrank. Here is what actually changed.",[23,1247,1249],{"id":1248},"before","Before",[11,1251,1252],{},"Every webhook handler did its own signature verification inline. Two routes, same boilerplate:",[31,1254,1256],{"className":133,"code":1255,"language":135,"meta":36,"style":36},"interface WebhookRequest extends FastifyRequest {\n  rawBody?: string | Buffer;\n}\n\nserver.post('\u002Fgithub', { schema, config: { rawBody: true } },\n  async (request: WebhookRequest, reply) => {\n    const signature = request.headers['x-hub-signature-256'] as string | undefined;\n    const secret = process.env.GITHUB_WEBHOOK_SECRET;\n    if (!secret) return reply.internalServerError('Webhook secret not configured');\n    if (!request.rawBody) return reply.internalServerError('Raw body not available');\n\n    const rawBodyString = typeof request.rawBody === 'string'\n      ? request.rawBody\n      : request.rawBody.toString('utf-8');\n\n    if (!verifyGitHubSignature(rawBodyString, signature, secret)) {\n      return reply.unauthorized('Invalid signature');\n    }\n\n    \u002F\u002F ... actual handler work starts here\n  }\n);\n",[38,1257,1258,1276,1296,1300,1304,1327,1356,1388,1405,1436,1461,1466,1488,1497,1516,1521,1536,1554,1560,1565,1571,1577],{"__ignoreMap":36},[41,1259,1260,1263,1267,1270,1273],{"class":43,"line":44},[41,1261,1262],{"class":81},"interface",[41,1264,1266],{"class":1265},"sScJk"," WebhookRequest",[41,1268,1269],{"class":81}," extends",[41,1271,1272],{"class":1265}," FastifyRequest",[41,1274,1275],{"class":47}," {\n",[41,1277,1278,1282,1285,1288,1291,1294],{"class":43,"line":62},[41,1279,1281],{"class":1280},"s4XuR","  rawBody",[41,1283,1284],{"class":81},"?:",[41,1286,1287],{"class":105}," string",[41,1289,1290],{"class":81}," |",[41,1292,1293],{"class":1265}," Buffer",[41,1295,157],{"class":47},[41,1297,1298],{"class":43,"line":73},[41,1299,204],{"class":47},[41,1301,1302],{"class":43,"line":85},[41,1303,179],{"emptyLinePlaceholder":178},[41,1305,1306,1309,1312,1315,1318,1321,1324],{"class":43,"line":91},[41,1307,1308],{"class":47},"server.",[41,1310,1311],{"class":1265},"post",[41,1313,1314],{"class":47},"(",[41,1316,1317],{"class":58},"'\u002Fgithub'",[41,1319,1320],{"class":47},", { schema, config: { rawBody: ",[41,1322,1323],{"class":105},"true",[41,1325,1326],{"class":47}," } },\n",[41,1328,1329,1332,1335,1338,1341,1343,1345,1348,1351,1354],{"class":43,"line":97},[41,1330,1331],{"class":81},"  async",[41,1333,1334],{"class":47}," (",[41,1336,1337],{"class":1280},"request",[41,1339,1340],{"class":81},":",[41,1342,1266],{"class":1265},[41,1344,867],{"class":47},[41,1346,1347],{"class":1280},"reply",[41,1349,1350],{"class":47},") ",[41,1352,1353],{"class":81},"=>",[41,1355,1275],{"class":47},[41,1357,1359,1362,1365,1367,1370,1373,1376,1379,1381,1383,1386],{"class":43,"line":1358},7,[41,1360,1361],{"class":81},"    const",[41,1363,1364],{"class":105}," signature",[41,1366,148],{"class":81},[41,1368,1369],{"class":47}," request.headers[",[41,1371,1372],{"class":58},"'x-hub-signature-256'",[41,1374,1375],{"class":47},"] ",[41,1377,1378],{"class":81},"as",[41,1380,1287],{"class":105},[41,1382,1290],{"class":81},[41,1384,1385],{"class":105}," undefined",[41,1387,157],{"class":47},[41,1389,1391,1393,1396,1398,1400,1403],{"class":43,"line":1390},8,[41,1392,1361],{"class":81},[41,1394,1395],{"class":105}," secret",[41,1397,148],{"class":81},[41,1399,151],{"class":47},[41,1401,1402],{"class":105},"GITHUB_WEBHOOK_SECRET",[41,1404,157],{"class":47},[41,1406,1408,1411,1413,1416,1419,1422,1425,1428,1430,1433],{"class":43,"line":1407},9,[41,1409,1410],{"class":81},"    if",[41,1412,1334],{"class":47},[41,1414,1415],{"class":81},"!",[41,1417,1418],{"class":47},"secret) ",[41,1420,1421],{"class":81},"return",[41,1423,1424],{"class":47}," reply.",[41,1426,1427],{"class":1265},"internalServerError",[41,1429,1314],{"class":47},[41,1431,1432],{"class":58},"'Webhook secret not configured'",[41,1434,1435],{"class":47},");\n",[41,1437,1439,1441,1443,1445,1448,1450,1452,1454,1456,1459],{"class":43,"line":1438},10,[41,1440,1410],{"class":81},[41,1442,1334],{"class":47},[41,1444,1415],{"class":81},[41,1446,1447],{"class":47},"request.rawBody) ",[41,1449,1421],{"class":81},[41,1451,1424],{"class":47},[41,1453,1427],{"class":1265},[41,1455,1314],{"class":47},[41,1457,1458],{"class":58},"'Raw body not available'",[41,1460,1435],{"class":47},[41,1462,1464],{"class":43,"line":1463},11,[41,1465,179],{"emptyLinePlaceholder":178},[41,1467,1469,1471,1474,1476,1479,1482,1485],{"class":43,"line":1468},12,[41,1470,1361],{"class":81},[41,1472,1473],{"class":105}," rawBodyString",[41,1475,148],{"class":81},[41,1477,1478],{"class":81}," typeof",[41,1480,1481],{"class":47}," request.rawBody ",[41,1483,1484],{"class":81},"===",[41,1486,1487],{"class":58}," 'string'\n",[41,1489,1491,1494],{"class":43,"line":1490},13,[41,1492,1493],{"class":81},"      ?",[41,1495,1496],{"class":47}," request.rawBody\n",[41,1498,1500,1503,1506,1509,1511,1514],{"class":43,"line":1499},14,[41,1501,1502],{"class":81},"      :",[41,1504,1505],{"class":47}," request.rawBody.",[41,1507,1508],{"class":1265},"toString",[41,1510,1314],{"class":47},[41,1512,1513],{"class":58},"'utf-8'",[41,1515,1435],{"class":47},[41,1517,1519],{"class":43,"line":1518},15,[41,1520,179],{"emptyLinePlaceholder":178},[41,1522,1524,1526,1528,1530,1533],{"class":43,"line":1523},16,[41,1525,1410],{"class":81},[41,1527,1334],{"class":47},[41,1529,1415],{"class":81},[41,1531,1532],{"class":1265},"verifyGitHubSignature",[41,1534,1535],{"class":47},"(rawBodyString, signature, secret)) {\n",[41,1537,1539,1542,1544,1547,1549,1552],{"class":43,"line":1538},17,[41,1540,1541],{"class":81},"      return",[41,1543,1424],{"class":47},[41,1545,1546],{"class":1265},"unauthorized",[41,1548,1314],{"class":47},[41,1550,1551],{"class":58},"'Invalid signature'",[41,1553,1435],{"class":47},[41,1555,1557],{"class":43,"line":1556},18,[41,1558,1559],{"class":47},"    }\n",[41,1561,1563],{"class":43,"line":1562},19,[41,1564,179],{"emptyLinePlaceholder":178},[41,1566,1568],{"class":43,"line":1567},20,[41,1569,1570],{"class":198},"    \u002F\u002F ... actual handler work starts here\n",[41,1572,1574],{"class":43,"line":1573},21,[41,1575,1576],{"class":47},"  }\n",[41,1578,1580],{"class":43,"line":1579},22,[41,1581,1435],{"class":47},[11,1583,1584,1587,1588,1591,1592,756],{},[38,1585,1586],{},"server\u002Fsrc\u002Froutes\u002Fwebhooks.ts"," was 249 lines. The GitHub handler was 131 lines, the Qodana handler 113. About 30 lines per handler were signature plumbing duplicated across both. Plus a ",[38,1589,1590],{},"WebhookRequest"," interface that existed only to widen ",[38,1593,1594],{},"FastifyRequest.rawBody",[11,1596,1597,1600],{},[38,1598,1599],{},"server\u002FCLAUDE.md"," had a five step procedure that new webhook routes had to follow \"or signature verification silently fails\". Step 4 was the actual check. Steps 1, 2, 3, 5 were ceremony.",[23,1602,1604],{"id":1603},"after","After",[11,1606,1607],{},"One plugin. 45 lines. Reads route config, runs the check, short circuits with the right status code:",[31,1609,1611],{"className":133,"code":1610,"language":135,"meta":36,"style":36},"\u002F\u002F server\u002Fsrc\u002Fplugins\u002Fwebhook-signature.ts\nconst webhookSignaturePlugin: FastifyPluginAsync = async (fastify) => {\n  fastify.addHook('preHandler', async (request, reply) => {\n    const sigConfig = request.routeOptions.config.webhookSignature;\n    if (!sigConfig) return;\n\n    const secret = process.env[sigConfig.secretEnv];\n    if (!secret) return reply.internalServerError('Webhook secret not configured');\n\n    const rawBody = (request as RequestWithRawBody).rawBody;\n    if (!rawBody) return reply.internalServerError('Raw body not available');\n\n    const rawBodyString = typeof rawBody === 'string' ? rawBody : rawBody.toString('utf-8');\n    const signature = request.headers[sigConfig.header.toLowerCase()] as string | undefined;\n\n    if (!verifyGitHubSignature(rawBodyString, signature, secret)) {\n      return reply.unauthorized('Invalid signature');\n    }\n  });\n};\n",[38,1612,1613,1618,1646,1678,1690,1705,1709,1720,1742,1746,1766,1789,1793,1829,1856,1860,1872,1886,1890,1895],{"__ignoreMap":36},[41,1614,1615],{"class":43,"line":44},[41,1616,1617],{"class":198},"\u002F\u002F server\u002Fsrc\u002Fplugins\u002Fwebhook-signature.ts\n",[41,1619,1620,1622,1625,1627,1630,1632,1635,1637,1640,1642,1644],{"class":43,"line":62},[41,1621,142],{"class":81},[41,1623,1624],{"class":1265}," webhookSignaturePlugin",[41,1626,1340],{"class":81},[41,1628,1629],{"class":1265}," FastifyPluginAsync",[41,1631,148],{"class":81},[41,1633,1634],{"class":81}," async",[41,1636,1334],{"class":47},[41,1638,1639],{"class":1280},"fastify",[41,1641,1350],{"class":47},[41,1643,1353],{"class":81},[41,1645,1275],{"class":47},[41,1647,1648,1651,1654,1656,1659,1661,1664,1666,1668,1670,1672,1674,1676],{"class":43,"line":73},[41,1649,1650],{"class":47},"  fastify.",[41,1652,1653],{"class":1265},"addHook",[41,1655,1314],{"class":47},[41,1657,1658],{"class":58},"'preHandler'",[41,1660,867],{"class":47},[41,1662,1663],{"class":81},"async",[41,1665,1334],{"class":47},[41,1667,1337],{"class":1280},[41,1669,867],{"class":47},[41,1671,1347],{"class":1280},[41,1673,1350],{"class":47},[41,1675,1353],{"class":81},[41,1677,1275],{"class":47},[41,1679,1680,1682,1685,1687],{"class":43,"line":85},[41,1681,1361],{"class":81},[41,1683,1684],{"class":105}," sigConfig",[41,1686,148],{"class":81},[41,1688,1689],{"class":47}," request.routeOptions.config.webhookSignature;\n",[41,1691,1692,1694,1696,1698,1701,1703],{"class":43,"line":91},[41,1693,1410],{"class":81},[41,1695,1334],{"class":47},[41,1697,1415],{"class":81},[41,1699,1700],{"class":47},"sigConfig) ",[41,1702,1421],{"class":81},[41,1704,157],{"class":47},[41,1706,1707],{"class":43,"line":97},[41,1708,179],{"emptyLinePlaceholder":178},[41,1710,1711,1713,1715,1717],{"class":43,"line":1358},[41,1712,1361],{"class":81},[41,1714,1395],{"class":105},[41,1716,148],{"class":81},[41,1718,1719],{"class":47}," process.env[sigConfig.secretEnv];\n",[41,1721,1722,1724,1726,1728,1730,1732,1734,1736,1738,1740],{"class":43,"line":1390},[41,1723,1410],{"class":81},[41,1725,1334],{"class":47},[41,1727,1415],{"class":81},[41,1729,1418],{"class":47},[41,1731,1421],{"class":81},[41,1733,1424],{"class":47},[41,1735,1427],{"class":1265},[41,1737,1314],{"class":47},[41,1739,1432],{"class":58},[41,1741,1435],{"class":47},[41,1743,1744],{"class":43,"line":1407},[41,1745,179],{"emptyLinePlaceholder":178},[41,1747,1748,1750,1753,1755,1758,1760,1763],{"class":43,"line":1438},[41,1749,1361],{"class":81},[41,1751,1752],{"class":105}," rawBody",[41,1754,148],{"class":81},[41,1756,1757],{"class":47}," (request ",[41,1759,1378],{"class":81},[41,1761,1762],{"class":1265}," RequestWithRawBody",[41,1764,1765],{"class":47},").rawBody;\n",[41,1767,1768,1770,1772,1774,1777,1779,1781,1783,1785,1787],{"class":43,"line":1463},[41,1769,1410],{"class":81},[41,1771,1334],{"class":47},[41,1773,1415],{"class":81},[41,1775,1776],{"class":47},"rawBody) ",[41,1778,1421],{"class":81},[41,1780,1424],{"class":47},[41,1782,1427],{"class":1265},[41,1784,1314],{"class":47},[41,1786,1458],{"class":58},[41,1788,1435],{"class":47},[41,1790,1791],{"class":43,"line":1468},[41,1792,179],{"emptyLinePlaceholder":178},[41,1794,1795,1797,1799,1801,1803,1806,1808,1811,1814,1816,1818,1821,1823,1825,1827],{"class":43,"line":1490},[41,1796,1361],{"class":81},[41,1798,1473],{"class":105},[41,1800,148],{"class":81},[41,1802,1478],{"class":81},[41,1804,1805],{"class":47}," rawBody ",[41,1807,1484],{"class":81},[41,1809,1810],{"class":58}," 'string'",[41,1812,1813],{"class":81}," ?",[41,1815,1805],{"class":47},[41,1817,1340],{"class":81},[41,1819,1820],{"class":47}," rawBody.",[41,1822,1508],{"class":1265},[41,1824,1314],{"class":47},[41,1826,1513],{"class":58},[41,1828,1435],{"class":47},[41,1830,1831,1833,1835,1837,1840,1843,1846,1848,1850,1852,1854],{"class":43,"line":1499},[41,1832,1361],{"class":81},[41,1834,1364],{"class":105},[41,1836,148],{"class":81},[41,1838,1839],{"class":47}," request.headers[sigConfig.header.",[41,1841,1842],{"class":1265},"toLowerCase",[41,1844,1845],{"class":47},"()] ",[41,1847,1378],{"class":81},[41,1849,1287],{"class":105},[41,1851,1290],{"class":81},[41,1853,1385],{"class":105},[41,1855,157],{"class":47},[41,1857,1858],{"class":43,"line":1518},[41,1859,179],{"emptyLinePlaceholder":178},[41,1861,1862,1864,1866,1868,1870],{"class":43,"line":1523},[41,1863,1410],{"class":81},[41,1865,1334],{"class":47},[41,1867,1415],{"class":81},[41,1869,1532],{"class":1265},[41,1871,1535],{"class":47},[41,1873,1874,1876,1878,1880,1882,1884],{"class":43,"line":1538},[41,1875,1541],{"class":81},[41,1877,1424],{"class":47},[41,1879,1546],{"class":1265},[41,1881,1314],{"class":47},[41,1883,1551],{"class":58},[41,1885,1435],{"class":47},[41,1887,1888],{"class":43,"line":1556},[41,1889,1559],{"class":47},[41,1891,1892],{"class":43,"line":1562},[41,1893,1894],{"class":47},"  });\n",[41,1896,1897],{"class":43,"line":1567},[41,1898,1899],{"class":47},"};\n",[11,1901,1902,1903,1906,1907,1910],{},"Registered once in ",[38,1904,1905],{},"server\u002Fsrc\u002Findex.ts",", after ",[38,1908,1909],{},"rawBody",". Routes opt in via config:",[31,1912,1914],{"className":133,"code":1913,"language":135,"meta":36,"style":36},"server.post('\u002Fgithub', {\n  schema: { \u002F* ... *\u002F },\n  config: {\n    rawBody: true,\n    webhookSignature: {\n      secretEnv: 'GITHUB_WEBHOOK_SECRET',\n      header: 'x-hub-signature-256',\n    },\n  },\n}, async (request, reply) => {\n  \u002F\u002F Handler only sees verified requests.\n});\n",[38,1915,1916,1929,1940,1945,1955,1960,1970,1979,1984,1989,2010,2015],{"__ignoreMap":36},[41,1917,1918,1920,1922,1924,1926],{"class":43,"line":44},[41,1919,1308],{"class":47},[41,1921,1311],{"class":1265},[41,1923,1314],{"class":47},[41,1925,1317],{"class":58},[41,1927,1928],{"class":47},", {\n",[41,1930,1931,1934,1937],{"class":43,"line":62},[41,1932,1933],{"class":47},"  schema: { ",[41,1935,1936],{"class":198},"\u002F* ... *\u002F",[41,1938,1939],{"class":47}," },\n",[41,1941,1942],{"class":43,"line":73},[41,1943,1944],{"class":47},"  config: {\n",[41,1946,1947,1950,1952],{"class":43,"line":85},[41,1948,1949],{"class":47},"    rawBody: ",[41,1951,1323],{"class":105},[41,1953,1954],{"class":47},",\n",[41,1956,1957],{"class":43,"line":91},[41,1958,1959],{"class":47},"    webhookSignature: {\n",[41,1961,1962,1965,1968],{"class":43,"line":97},[41,1963,1964],{"class":47},"      secretEnv: ",[41,1966,1967],{"class":58},"'GITHUB_WEBHOOK_SECRET'",[41,1969,1954],{"class":47},[41,1971,1972,1975,1977],{"class":43,"line":1358},[41,1973,1974],{"class":47},"      header: ",[41,1976,1372],{"class":58},[41,1978,1954],{"class":47},[41,1980,1981],{"class":43,"line":1390},[41,1982,1983],{"class":47},"    },\n",[41,1985,1986],{"class":43,"line":1407},[41,1987,1988],{"class":47},"  },\n",[41,1990,1991,1994,1996,1998,2000,2002,2004,2006,2008],{"class":43,"line":1438},[41,1992,1993],{"class":47},"}, ",[41,1995,1663],{"class":81},[41,1997,1334],{"class":47},[41,1999,1337],{"class":1280},[41,2001,867],{"class":47},[41,2003,1347],{"class":1280},[41,2005,1350],{"class":47},[41,2007,1353],{"class":81},[41,2009,1275],{"class":47},[41,2011,2012],{"class":43,"line":1463},[41,2013,2014],{"class":198},"  \u002F\u002F Handler only sees verified requests.\n",[41,2016,2017],{"class":43,"line":1468},[41,2018,2019],{"class":47},"});\n",[11,2021,2022,2023,2026],{},"The Qodana route uses the same plugin with ",[38,2024,2025],{},"secretEnv: 'QODANA_WEBHOOK_SECRET'"," and a different header.",[23,2028,2030],{"id":2029},"the-deltas","The deltas",[2032,2033,2034,2051],"table",{},[2035,2036,2037],"thead",{},[2038,2039,2040,2044,2046,2048],"tr",{},[2041,2042,2043],"th",{},"File",[2041,2045,1249],{},[2041,2047,1604],{},[2041,2049,2050],{},"Change",[2052,2053,2054,2070,2086,2101],"tbody",{},[2038,2055,2056,2061,2064,2067],{},[2057,2058,2059],"td",{},[38,2060,1586],{},[2057,2062,2063],{},"249 lines",[2057,2065,2066],{},"192 lines",[2057,2068,2069],{},"-57",[2038,2071,2072,2077,2080,2083],{},[2057,2073,2074],{},[38,2075,2076],{},"server\u002Fsrc\u002Fplugins\u002Fwebhook-signature.ts",[2057,2078,2079],{},"absent",[2057,2081,2082],{},"45 lines",[2057,2084,2085],{},"+45",[2038,2087,2088,2092,2095,2098],{},[2057,2089,2090],{},[38,2091,1599],{},[2057,2093,2094],{},"28 lines",[2057,2096,2097],{},"11 lines",[2057,2099,2100],{},"-17",[2038,2102,2103,2109,2112,2115],{},[2057,2104,2105,2108],{},[38,2106,2107],{},"CLAUDE.md"," (root)",[2057,2110,2111],{},"24 lines",[2057,2113,2114],{},"19 lines",[2057,2116,2117],{},"-5",[11,2119,2120],{},"Net source change: about 12 fewer lines overall, but the meaningful delta is where the logic lives. Signature verification used to be duplicated across two handlers plus documented in a CLAUDE.md procedure. Now it lives in one place, invoked declaratively.",[11,2122,2123,2124,2127,2128,794,2131,2133],{},"Tests: ",[38,2125,2126],{},"server\u002Ftest\u002Froutes\u002Fwebhooks.test.ts"," runs 24 cases. The only change needed was registering the plugin in test setup alongside ",[38,2129,2130],{},"sensible",[38,2132,1909],{},". All 24 still pass with the same assertions.",[23,2135,2137],{"id":2136},"what-the-plugin-buys","What the plugin buys",[11,2139,2140,2143,2144,2147],{},[413,2141,2142],{},"Failure mode changed."," Before, omitting the signature check was a silent \"my endpoint accepts forged webhooks\" bug. After, the thing you forget is setting a config flag called ",[38,2145,2146],{},"webhookSignature",". Its absence is visible at PR review. You can still write an insecure route, but you cannot do it by accident.",[11,2149,2150,2153],{},[413,2151,2152],{},"Error paths unified."," Missing secret, missing raw body, invalid signature: all three now return from the same preHandler with consistent status codes (500, 500, 401). Handlers no longer branch on these cases.",[11,2155,2156,2159,2160,2163],{},[413,2157,2158],{},"Handler bodies are about the work."," The GitHub handler now starts with event routing. The Qodana handler starts with ",[38,2161,2162],{},"processQodanaReport",". No 30 line preamble on either.",[11,2165,2166,2169,2170,2172,2173,756],{},[413,2167,2168],{},"Type narrowing came for free."," The handlers dropped the ",[38,2171,1590],{}," interface. By the time a handler runs, the signature has been verified at the hook layer, and the handler signature is back to plain ",[38,2174,2175],{},"(request, reply)",[11,2177,2178,2181,2182,2185],{},[413,2179,2180],{},"Config is the API."," Adding a third webhook (say, Stripe) is now: declare ",[38,2183,2184],{},"config.webhookSignature = { secretEnv: 'STRIPE_WEBHOOK_SECRET', header: 'stripe-signature' }",". No copying verification boilerplate from another route.",[23,2187,2189],{"id":2188},"what-the-claudemd-deletion-buys","What the CLAUDE.md deletion buys",[11,2191,2192,2193,2195],{},"The five step procedure in ",[38,2194,1599],{}," was not just noise. It was a signal that signature verification was manual, easy to get wrong, and not enforceable at PR review without the document as a checklist. Removing it is only legitimate because the thing it described got replaced by a mechanism that cannot be skipped by accident.",[11,2197,2198,2199,2203],{},"That is the point of the exercise. A line in CLAUDE.md that tells you to remember to do a thing is a bug report against the codebase, the idea behind our ",[14,2200,2202],{"href":2201},"\u002Fblog\u002Flandmines","Landmines"," rule. Fixing it in code lets you delete the line. The file shrinks every time we turn a convention into a constraint, which is the direction it should go.",[23,2205,2207],{"id":2206},"what-is-still-manual","What is still manual",[11,2209,2210],{},"One CLAUDE.md landmine still stands:",[2212,2213,2214],"blockquote",{},[11,2215,2216,2217,2220],{},"API client types in ",[38,2218,2219],{},"web\u002Fsrc\u002Flib\u002Fapi\u002Fclient.ts"," are manual. If you change a server endpoint, update client types by hand.",[11,2222,2223,2224,2227,2228,2231,2232,2235,2236,2239],{},"The fix there is an OpenAPI codegen pipeline: export the spec from ",[38,2225,2226],{},"@fastify\u002Fswagger",", run ",[38,2229,2230],{},"openapi-typescript",", swap ",[38,2233,2234],{},"client.ts"," to use the generated types. The plumbing is cheap. The expensive part is auditing every route to declare a complete ",[38,2237,2238],{},"response"," schema, because the generated types are only as good as the spec. That is a separate refactor, scoped separately.",[348,2241,2242],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":36,"searchDepth":62,"depth":62,"links":2244},[2245,2246,2247,2248,2249,2250],{"id":1248,"depth":62,"text":1249},{"id":1603,"depth":62,"text":1604},{"id":2029,"depth":62,"text":2030},{"id":2136,"depth":62,"text":2137},{"id":2188,"depth":62,"text":2189},{"id":2206,"depth":62,"text":2207},"2026-04-17T08:26:12.000Z","A five step CLAUDE.md procedure for webhook signature verification, easy to forget and silent when you did, became one Fastify plugin. The safe path stopped being something you remember and became something you cannot skip by accident.",{},"\u002Fblog\u002Ffrom-convention-to-plugin",{"title":1240,"description":2252},"blog\u002Ffrom-convention-to-plugin",[373,1639,2258],"security","0gTfQ6iSdwbUjSkM_vwfy7RQDk9Eb--RuUHYAQbrAgM",{"id":2261,"title":2262,"author":6,"body":2263,"category":359,"date":2544,"description":2545,"draft":362,"extension":363,"image":364,"meta":2546,"navigation":178,"path":2547,"project":367,"readingMinutes":91,"seo":2548,"stem":2549,"tags":2550,"__hash__":2551},"blog\u002Fblog\u002Fintegrate-against-the-trunk.md","Integrate Against the Trunk, Not Your Copy of It",{"type":8,"value":2264,"toc":2537},[2265,2273,2300,2310,2313,2317,2322,2335,2344,2353,2357,2363,2366,2372,2376,2383,2389,2401,2412,2416,2419,2422,2462,2468,2471,2499,2503,2511,2514,2523,2526,2528,2534],[11,2266,2267,2268,2272],{},"When we untangled ",[14,2269,2271],{"href":2270},"\u002Fblog\u002Fthe-conflict-nobody-wrote","the conflict nobody wrote",", the fix came down to one instruction: put the branch on top of the current trunk and regenerate. Written out, it read like this.",[31,2274,2278],{"className":2275,"code":2276,"language":2277,"meta":36,"style":36},"language-bash shiki shiki-themes github-light github-dark","git fetch origin\ngit rebase origin\u002Fmain\n","bash",[38,2279,2280,2290],{"__ignoreMap":36},[41,2281,2282,2284,2287],{"class":43,"line":44},[41,2283,533],{"class":1265},[41,2285,2286],{"class":58}," fetch",[41,2288,2289],{"class":58}," origin\n",[41,2291,2292,2294,2297],{"class":43,"line":62},[41,2293,533],{"class":1265},[41,2295,2296],{"class":58}," rebase",[41,2298,2299],{"class":58}," origin\u002Fmain\n",[11,2301,2302,2303,2306,2307,2309],{},"A fair question came back. Why ",[38,2304,2305],{},"origin\u002Fmain",", when there is a perfectly good ",[38,2308,789],{}," sitting right there in the clone? If the trunk has already been pulled in, can the rebase not use the local one?",[11,2311,2312],{},"It can. The answer is yes, and the yes has a condition attached that is worth more than the answer.",[23,2314,2316],{"id":2315},"two-names-and-only-one-of-them-is-the-trunk","Two names, and only one of them is the trunk",[11,2318,2319,2321],{},[38,2320,789],{}," is a branch in your repository. It is a file containing a commit id, and it moves only when you move it, by committing, merging, pulling, or resetting.",[11,2323,2324,2326,2327,2330,2331,2334],{},[38,2325,2305],{}," is a ",[413,2328,2329],{},"remote tracking reference",". It is also a file containing a commit id, and it moves only when you run ",[38,2332,2333],{},"git fetch",". What it holds is not what the remote has. It is what the remote had the last time you asked.",[11,2336,2337,2338,2340,2341,2343],{},"So there are three versions of the trunk in play at any moment, and it is easy to believe there is one. There is the trunk on the server, which is the real one, the one everybody else is integrating with and the one the forge uses to decide whether your pull request can merge. There is ",[38,2339,2305],{}," in your clone, a snapshot of the server taken at your last fetch. And there is ",[38,2342,789],{}," in your clone, which is a branch you happen to have given the same name as the trunk, and which git treats as no more authoritative than any other branch you have made.",[11,2345,2346,794,2349,2352],{},[38,2347,2348],{},"git rebase main",[38,2350,2351],{},"git rebase origin\u002Fmain"," are identical commands whenever those two names resolve to the same commit. When they do not, one of them is integration and the other is a rehearsal.",[23,2354,2356],{"id":2355},"the-failure-is-silent-which-is-the-problem","The failure is silent, which is the problem",[11,2358,2359,2360,2362],{},"Suppose your local ",[38,2361,789],{}," is two days old. You rebase onto it. The rebase runs, you resolve the conflict thoughtfully, the tests pass, you push, and the pull request still says conflicting.",[11,2364,2365],{},"Nothing warned you. The rebase did not fail. It could not fail, because you asked it to replay your work on top of a commit and it did precisely that. You integrated with a version of the trunk that exists nowhere but your disk, and every minute you spent resolving that conflict was spent answering a question nobody had asked.",[11,2367,2368,2369,2371],{},"There is a second, worse shape. If your local ",[38,2370,789],{}," carries commits you have not pushed, perhaps something you committed there weeks ago and forgot, rebasing onto it slides those commits underneath your branch. The pull request now contains work unrelated to the change it claims to make, and the reviewer, reading a diff about a quota, finds a stray refactor in it.",[23,2373,2375],{"id":2374},"the-trap-that-makes-it-convincing","The trap that makes it convincing",[11,2377,2378,2379,2382],{},"Here is the part that catches careful people. Run ",[38,2380,2381],{},"git status"," on a stale clone and it will tell you, with total confidence:",[31,2384,2387],{"className":2385,"code":2386,"language":122},[120],"On branch main\nYour branch is up to date with 'origin\u002Fmain'.\n",[38,2388,2386],{"__ignoreMap":36},[11,2390,2391,2392,2394,2395,2397,2398,2400],{},"That sentence is true and it does not mean what it appears to mean. Git is comparing your ",[38,2393,789],{}," against your ",[38,2396,2305],{},", both of which are local files. It is telling you that your copy agrees with your copy. It has not spoken to the server, because ",[38,2399,2381],{}," never speaks to the server. Two days of other people's work can sit on the trunk while your clone insists everything is current.",[11,2402,2403,2404,2407,2408,2411],{},"The same applies to ",[38,2405,2406],{},"git log origin\u002Fmain",", to the ahead and behind counts in your editor's status bar, and to every other reading of the world that has not been preceded by a fetch. ",[413,2409,2410],{},"Nothing in git reaches across the network unless you ask it to."," The remote tracking reference is a cache, and, exactly as we found with the generated document in the previous post, a cache tells you what was true when it was written.",[23,2413,2415],{"id":2414},"the-practice-underneath","The practice underneath",[11,2417,2418],{},"The old discipline for integrating has two steps and people usually remember only the second. Before you put your work into the repository, you bring the repository's work into yours, and you prove that the combination builds and passes its tests. The first step is not \"update your local main\". It is \"get what everybody else has\".",[11,2420,2421],{},"So the shape of an honest integration is:",[31,2423,2425],{"className":2275,"code":2424,"language":2277,"meta":36,"style":36},"git fetch origin              # go and actually look\ngit rebase origin\u002Fmain        # integrate with what is there\nnpm test                      # prove the combination works\n",[38,2426,2427,2439,2451],{"__ignoreMap":36},[41,2428,2429,2431,2433,2436],{"class":43,"line":44},[41,2430,533],{"class":1265},[41,2432,2286],{"class":58},[41,2434,2435],{"class":58}," origin",[41,2437,2438],{"class":198},"              # go and actually look\n",[41,2440,2441,2443,2445,2448],{"class":43,"line":62},[41,2442,533],{"class":1265},[41,2444,2296],{"class":58},[41,2446,2447],{"class":58}," origin\u002Fmain",[41,2449,2450],{"class":198},"        # integrate with what is there\n",[41,2452,2453,2456,2459],{"class":43,"line":73},[41,2454,2455],{"class":1265},"npm",[41,2457,2458],{"class":58}," test",[41,2460,2461],{"class":198},"                      # prove the combination works\n",[11,2463,2464,2465,2467],{},"The fetch is the only line that touches the network, and it is the only line that turns a private opinion about the trunk into a fact about it. If you prefer to work through a local ",[38,2466,789],{},", fast forward it first and then use it. The name you type afterwards is a matter of taste. Having fetched is not.",[11,2469,2470],{},"Two commands settle the question when you are unsure:",[31,2472,2474],{"className":2275,"code":2473,"language":2277,"meta":36,"style":36},"git fetch origin\ngit rev-parse main origin\u002Fmain   # same commit id twice? then either name works\n",[38,2475,2476,2484],{"__ignoreMap":36},[41,2477,2478,2480,2482],{"class":43,"line":44},[41,2479,533],{"class":1265},[41,2481,2286],{"class":58},[41,2483,2289],{"class":58},[41,2485,2486,2488,2491,2494,2496],{"class":43,"line":62},[41,2487,533],{"class":1265},[41,2489,2490],{"class":58}," rev-parse",[41,2492,2493],{"class":58}," main",[41,2495,2447],{"class":58},[41,2497,2498],{"class":198},"   # same commit id twice? then either name works\n",[23,2500,2502],{"id":2501},"why-the-difference-is-the-point","Why the difference is the point",[11,2504,2505,2506,794,2508,2510],{},"It would be easy to file this under command line trivia. It is not, and the reason is that the gap between ",[38,2507,789],{},[38,2509,2305],{}," is a measurement.",[11,2512,2513],{},"If those two references have drifted apart, you are looking at work that other people have finished and you have not yet reckoned with. That is the definition of being unintegrated. The size of the gap is the size of the surprise waiting for you, and the only thing that shrinks it is fetching more often and integrating more often.",[11,2515,2516,2517,2519,2520,2522],{},"Which means the question \"should I use ",[38,2518,789],{}," or ",[38,2521,2305],{},"?\" answers itself under a discipline of continuous integration. A team that brings the trunk into their work every few hours will find the two names pointing at the same commit almost every time they look, and the distinction will feel academic. A team that discovers a real difference between them has not found a naming subtlety. They have found out how far behind they are.",[11,2524,2525],{},"Our own conflict came from a branch that was one merge behind the trunk. One. That is how little divergence it takes, and it is the subject of the next two posts: what a branch costs while you are not looking at it, and why the argument about how to catch up is less interesting than the fact that you had to.",[335,2527],{},[11,2529,2530,343,2532],{},[340,2531,342],{},[340,2533,346],{},[348,2535,2536],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":36,"searchDepth":62,"depth":62,"links":2538},[2539,2540,2541,2542,2543],{"id":2315,"depth":62,"text":2316},{"id":2355,"depth":62,"text":2356},{"id":2374,"depth":62,"text":2375},{"id":2414,"depth":62,"text":2415},{"id":2501,"depth":62,"text":2502},"2026-07-09T09:22:00.000Z","A rebase onto a stale local main succeeds, looks resolved, and leaves the pull request still conflicting. The trunk is the shared branch. Everything on your machine is a photograph of it, and git will not tell you when the photograph is old.",{},"\u002Fblog\u002Fintegrate-against-the-trunk",{"title":2262,"description":2545},"blog\u002Fintegrate-against-the-trunk",[533,371,372],"hAg05nEPxPsE3YJulNZCo7W_3Jp9dJhP5r0xnknehbs",{"id":2553,"title":2202,"author":6,"body":2554,"category":525,"date":2676,"description":2677,"draft":362,"extension":363,"image":364,"meta":2678,"navigation":178,"path":2201,"project":367,"readingMinutes":85,"seo":2679,"stem":2680,"tags":2681,"__hash__":2684},"blog\u002Fblog\u002Flandmines.md",{"type":8,"value":2555,"toc":2670},[2556,2559,2562,2565,2568,2572,2575,2604,2607,2614,2617,2621,2624,2632,2635,2638,2642,2645,2648,2651,2654,2658,2661,2664,2667],[11,2557,2558],{},"There is a discipline that has become almost mandatory on codebases that work with AI agents. You write a CLAUDE.md, or an AGENTS.md, or whatever the file is called this week, and you fill it with the context you wish a new contributor had. Most of these files are too long. Most of what goes in them is discoverable by reading the code. And most of them grow rather than shrink, because documenting a convention feels like progress.",[11,2560,2561],{},"At EkoHacks we use a tighter rule, borrowed from Addy Osmani: the file is for landmines, and nothing else.",[11,2563,2564],{},"A landmine is a fact about the codebase that cannot be discovered by reading the code, will cost time or correctness if you step on it, and is operationally significant. Port numbers are a landmine. The directory tree is not. A silently enforced convention is a landmine. The conventions your linter catches are not.",[11,2566,2567],{},"Here are two examples from our own repo. One came out, one is still on the list.",[23,2569,2571],{"id":2570},"the-one-that-came-out","The one that came out",[11,2573,2574],{},"The server CLAUDE.md had a five step procedure at the top. New webhook route, follow this exact order or signature verification silently fails.",[572,2576,2577,2580,2586,2589,2595],{},[575,2578,2579],{},"Extend the request type for raw body access.",[575,2581,2582,2583,756],{},"Set ",[38,2584,2585],{},"config: { rawBody: true }",[575,2587,2588],{},"Convert rawBody to string.",[575,2590,2591,2592,756],{},"Call ",[38,2593,2594],{},"verifyGitHubSignature(rawBody, signature, secret)",[575,2596,2597,2598,2601,2602,756],{},"Handlers take ",[38,2599,2600],{},"(payload, logger)",", not ",[38,2603,2175],{},[11,2605,2606],{},"Step 4 is the actual security check. The other four steps are the ceremony around remembering to do step 4. Every new webhook was a chance to forget one of them, and \"silently fails\" was doing a lot of work in that sentence.",[11,2608,2609,2610,2613],{},"We replaced it with a Fastify plugin. The plugin reads a flag off the route config and runs the signature check as a preHandler. The route declares ",[38,2611,2612],{},"config: { webhookSignature: { secretEnv, header } }"," and that is the whole contract. Forgetting the check does not silently pass. It visibly omits a config key that sits right next to the route schema.",[11,2615,2616],{},"Five steps of documentation replaced by one config flag in code. The CLAUDE.md section was deleted the same day.",[23,2618,2620],{"id":2619},"the-one-still-on-the-list","The one still on the list",[11,2622,2623],{},"Another line, still in the landmines section:",[2212,2625,2626],{},[11,2627,2628,2629,756],{},"PostgreSQL runs on port 5433, not 5432, via ",[38,2630,2631],{},"docker-compose.yml",[11,2633,2634],{},"The port is non standard because several of us have a conventional Postgres running locally for other work. Docker assigns 5433 to dodge the clash. An agent that connects to 5432 finds a different database, gets confused, and writes the wrong data, or it fails loudly at a moment nobody is watching.",[11,2636,2637],{},"This one stays. The fix would be to switch to 5432 and live with the clash, or to make everyone reorganise their local Postgres. Neither is worth the move. The landmine is cheap to document and the fix is expensive. Keep.",[23,2639,2641],{"id":2640},"the-principle","The principle",[11,2643,2644],{},"A line in CLAUDE.md that tells you to remember to do a thing is a bug report against the codebase. The fact that you can train an agent to read the line and follow it does not mean the line is doing useful work. It means the line is masking a structural problem the code could fix.",[11,2646,2647],{},"When a landmine appears in the file, the first question is not \"is this well phrased\" but \"can we delete it by changing the code.\" If yes, do that. If no, it earns its place. Most of the time the answer is yes, and most teams never ask.",[11,2649,2650],{},"We also moved 416 lines of hand written TypeScript types out of our frontend recently. The CLAUDE.md landmine was that the client types were manual and drifted from the server. The fix was an OpenAPI codegen pipeline. The line got reworded to describe the new automatic workflow, and the 416 lines of drifting types collapsed to about 115 that derive from the server's schema. Six bugs that had been shipping silently fell out as a side effect.",[11,2652,2653],{},"Both of these started as landmines and ended as refactors. Both of them shrank the CLAUDE.md in the process. That is the direction the file is supposed to go.",[23,2655,2657],{"id":2656},"what-the-list-is-for","What the list is for",[11,2659,2660],{},"CLAUDE.md is not the onboarding document. It is not the architecture doc. It is not the style guide. It is a living list of friction nobody has fixed yet.",[11,2662,2663],{},"Every line is a ticket. Most tickets are cheap to close. The ones that stay are the ones where the cost of the fix is higher than the cost of the warning. You earn your landmines. You do not accumulate them.",[11,2665,2666],{},"When we start a new client project, we open the CLAUDE.md with a single sentence at the top. If you encounter something surprising or confusing, flag it. Over the first few weeks the file grows as the agent and the team notice traps. Then we go through it together and fix the ones we can. The file shrinks back down. That rhythm is the whole point.",[11,2668,2669],{},"The question to ask of any CLAUDE.md is not \"what should go in it.\" It is \"what can we take out.\" Answer that one honestly and the rest of the file writes itself.",{"title":36,"searchDepth":62,"depth":62,"links":2671},[2672,2673,2674,2675],{"id":2570,"depth":62,"text":2571},{"id":2619,"depth":62,"text":2620},{"id":2640,"depth":62,"text":2641},{"id":2656,"depth":62,"text":2657},"2026-04-17T10:15:06.000Z","Most CLAUDE.md files grow because documenting a convention feels like progress. We keep ours to landmines only: facts you cannot discover from the code, costly to step on, and every line a ticket to close by fixing the code.",{},{"title":2202,"description":2677},"blog\u002Flandmines",[373,2682,2683],"documentation","ai-agents","2KfEnHeq-FC9OSlmyEcbDDwTLMVEfCuxksR_cGB37cs",{"id":2686,"title":2687,"author":6,"body":2688,"category":359,"date":2995,"description":2996,"draft":362,"extension":363,"image":364,"meta":2997,"navigation":178,"path":16,"project":367,"readingMinutes":97,"seo":2998,"stem":2999,"tags":3000,"__hash__":3002},"blog\u002Fblog\u002Fnever-break-the-build.md","Never Break the Build",{"type":8,"value":2689,"toc":2989},[2690,2693,2696,2703,2706,2710,2713,2716,2719,2723,2726,2729,2732,2735,2738,2742,2745,2755,2844,2847,2856,2859,2901,2908,2911,2917,2928,2931,2934,2945,2952,2955,2959,2966,2975,2978,2980,2986],[11,2691,2692],{},"Think about the last bug that cost you an afternoon. Not the difficulty of the fix, which was probably one line, but the search: the hours before you knew where to look. Whose code was it in? Was it the library upgrade from Tuesday? Was it your machine?",[11,2694,2695],{},"Now imagine you knew, with certainty, that the entire system was working five minutes ago. The search collapses. Whatever is wrong lives in five minutes of changes, all of them yours, all of them fresh in your memory. You do not debug. You read the error message, look at the four lines you just wrote, and see it.",[11,2697,2698,2699,2702],{},"This is the whole argument for a green trunk, and it is not the argument people usually give. The reason to keep the build passing is not discipline for its own sake, nor tidiness, nor pride. ",[413,2700,2701],{},"Keeping the build green is what makes the space of possible causes small."," Every hour the trunk is red, that space grows to include everything anyone has done since, plus the original breakage, plus every interaction between them.",[11,2704,2705],{},"Speed in software is not typing quickly. It is never having to search a large space.",[23,2707,2709],{"id":2708},"the-agreement-and-why-it-cannot-be-installed","The agreement, and why it cannot be installed",[11,2711,2712],{},"The uncomfortable part is that this cannot be bought or configured. It is a promise a team makes to each other: we will not leave the trunk broken.",[11,2714,2715],{},"Teams try to skip the promise and buy the outcome, usually by installing a server that turns red and sends email. It does not work, and the way it fails is instructive. One or two people care about the build. They set up the notifications, hoping the visibility will shame everyone into compliance. What happens instead is that the people who did not agree to the practice treat the red as somebody else's alarm, because it is. The one or two people end up fixing every break themselves, resented, exhausted, and increasingly strident. The build ends up more broken than before, because now there is someone whose job it implicitly is.",[11,2717,2718],{},"You cannot impose this practice on a team that has not chosen it. Discuss the trade offs together, decide together, and if the answer is no, then the answer is no and a pipeline will not change it. This is the rare engineering practice where the social step genuinely precedes the technical one.",[23,2720,2722],{"id":2721},"roll-back-before-you-fix-forward","Roll back before you fix forward",[11,2724,2725],{},"When the build does break, there is a reflex to fix it. The reflex is usually wrong, and the reason is arithmetic rather than machismo.",[11,2727,2728],{},"Fixing forward is a bet: that you understand the failure well enough to correct it in a few minutes. Sometimes you win. When you lose, the trunk stays red while you investigate, and everyone else's search space grows for as long as you are wrong.",[11,2730,2731],{},"Reverting is not a bet. It is a return to a state that provably worked, and it costs one command. Your work is not lost, it is in your branch and in your reflog, and you can take your time understanding what went wrong from the safety of a green trunk. The instinct that reverting is an admission of failure has it backwards. Reverting is the cheap option, and taking the cheap option quickly is what competence looks like from the outside.",[11,2733,2734],{},"This is also why a version control system you cannot revert quickly and confidently is a hazard rather than an inconvenience. If getting back to the last known good state is a research project, you will fix forward every time, because you will have no choice.",[11,2736,2737],{},"The related rule is one people break under pressure: never repair the build by editing the integration machine. If the code worked on your laptop and fails on the runner, the fault is almost always something missing from the repository, a file not added, a dependency not declared, a step missing from the build script. Patch the machine and the build goes green while the repository stays broken, which means the next person to clone it cannot build, and now the failure is invisible and inherited. Fix the repository. Let the machine stay stupid.",[23,2739,2741],{"id":2740},"a-check-that-cannot-fail-is-not-a-gate","A check that cannot fail is not a gate",[11,2743,2744],{},"Which brings us to a discovery in our own pipeline, made while writing this series.",[11,2746,2747,2750,2751,2754],{},[38,2748,2749],{},"dora.yml"," runs each check with ",[38,2752,2753],{},"continue-on-error: true",", then converts the recorded outcomes into real failures at the bottom of the file:",[31,2756,2758],{"className":33,"code":2757,"language":35,"meta":36,"style":36},"      - name: Run server tests\n        id: unit_tests\n        run: cd server && npm run test\n        continue-on-error: true\n\n      # ... many steps later ...\n\n      - name: Fail if tests failed\n        if: steps.unit_tests.outcome == 'failure'\n        run: exit 1\n",[38,2759,2760,2772,2782,2792,2801,2805,2810,2814,2825,2835],{"__ignoreMap":36},[41,2761,2762,2765,2767,2769],{"class":43,"line":44},[41,2763,2764],{"class":47},"      - ",[41,2766,52],{"class":51},[41,2768,55],{"class":47},[41,2770,2771],{"class":58},"Run server tests\n",[41,2773,2774,2777,2779],{"class":43,"line":62},[41,2775,2776],{"class":51},"        id",[41,2778,55],{"class":47},[41,2780,2781],{"class":58},"unit_tests\n",[41,2783,2784,2787,2789],{"class":43,"line":73},[41,2785,2786],{"class":51},"        run",[41,2788,55],{"class":47},[41,2790,2791],{"class":58},"cd server && npm run test\n",[41,2793,2794,2797,2799],{"class":43,"line":85},[41,2795,2796],{"class":51},"        continue-on-error",[41,2798,55],{"class":47},[41,2800,106],{"class":105},[41,2802,2803],{"class":43,"line":91},[41,2804,179],{"emptyLinePlaceholder":178},[41,2806,2807],{"class":43,"line":97},[41,2808,2809],{"class":198},"      # ... many steps later ...\n",[41,2811,2812],{"class":43,"line":1358},[41,2813,179],{"emptyLinePlaceholder":178},[41,2815,2816,2818,2820,2822],{"class":43,"line":1390},[41,2817,2764],{"class":47},[41,2819,52],{"class":51},[41,2821,55],{"class":47},[41,2823,2824],{"class":58},"Fail if tests failed\n",[41,2826,2827,2830,2832],{"class":43,"line":1407},[41,2828,2829],{"class":51},"        if",[41,2831,55],{"class":47},[41,2833,2834],{"class":58},"steps.unit_tests.outcome == 'failure'\n",[41,2836,2837,2839,2841],{"class":43,"line":1438},[41,2838,2786],{"class":51},[41,2840,55],{"class":47},[41,2842,2843],{"class":58},"exit 1\n",[11,2845,2846],{},"That looks like a mistake and is in fact rather elegant. Between the two halves sits a step that posts the run's outcome to our own metrics API, because the platform we are building measures engineering health and eats its own dog food. Broken build rate is one of the things it measures. To record a red run, the job has to survive long enough to report it, and only then die. Deferring the failure is deliberate.",[11,2848,2849,2850,2853,2854,756],{},"There are five checks with a matching ",[38,2851,2852],{},"Fail if"," step: server tests, lint, server type check, web type check, and the behaviour document gate we wrote about in ",[14,2855,2271],{"href":2270},[11,2857,2858],{},"There is a sixth check with no matching step.",[31,2860,2862],{"className":33,"code":2861,"language":35,"meta":36,"style":36},"      - name: Check story coverage doc is current\n        id: docs_stories\n        run: cd server && npm run docs:stories:check\n        continue-on-error: true\n",[38,2863,2864,2875,2884,2893],{"__ignoreMap":36},[41,2865,2866,2868,2870,2872],{"class":43,"line":44},[41,2867,2764],{"class":47},[41,2869,52],{"class":51},[41,2871,55],{"class":47},[41,2873,2874],{"class":58},"Check story coverage doc is current\n",[41,2876,2877,2879,2881],{"class":43,"line":62},[41,2878,2776],{"class":51},[41,2880,55],{"class":47},[41,2882,2883],{"class":58},"docs_stories\n",[41,2885,2886,2888,2890],{"class":43,"line":73},[41,2887,2786],{"class":51},[41,2889,55],{"class":47},[41,2891,2892],{"class":58},"cd server && npm run docs:stories:check\n",[41,2894,2895,2897,2899],{"class":43,"line":85},[41,2896,2796],{"class":51},[41,2898,55],{"class":47},[41,2900,106],{"class":105},[11,2902,2903,2904,2907],{},"Nothing downstream reads ",[38,2905,2906],{},"steps.docs_stories.outcome",". It is absent from the status calculation, absent from the metrics we publish, and absent from the list of things that can turn the job red. The check runs on every commit. It prints its verdict into a log nobody opens. It has never once stopped anything.",[11,2909,2910],{},"So we went and ran it against the trunk, expecting to make a rhetorical point and find the document fine.",[31,2912,2915],{"className":2913,"code":2914,"language":122},[120],"$ npx tsx scripts\u002Fgenerate-story-coverage.ts --check\nAPP-STORIES.md is stale. Run: npm run docs:stories\n$ echo $?\n1\n",[38,2916,2914],{"__ignoreMap":36},[11,2918,2919,2920,2923,2924,2927],{},"The document had already rotted, and the drift was the very thing this series opened with. ",[38,2921,2922],{},"APP-STORIES.md"," records the line numbers of tests. It first went stale several merges back, when a change moved the developer snapshot tests, and the quota work from the first post then pushed ",[38,2925,2926],{},"participants.integration.test.ts"," down by sixty four lines on top of that. Its sibling document, the one with a working gate, was regenerated each time. This one never was. The check saw it every single run, said so, exited with a failure code, and the workflow shrugged and went green.",[11,2929,2930],{},"It is not a gate. It is a comment that consumes a runner.",[11,2932,2933],{},"Looking properly turned up a second one, worse in kind. The web client is typed from the server's OpenAPI specification, and both the spec and the generated types are committed. Change a route's schema without regenerating them and the frontend compiles happily against an API that no longer exists. Nothing checked this. Nothing had ever checked it. And it had already bitten: when the administrator plan route merged, the committed spec described no such endpoint, and stayed that way until the next pull request happened to regenerate it. The drift lasted one merge and healed by luck rather than by design, which is the least reassuring way for a problem to go away.",[11,2935,2936,2937,2940,2941,2944],{},"We are grateful for it, in a backhanded way, because it is such a clean specimen. ",[413,2938,2939],{},"A check that cannot fail is not a check."," It is theatre, and theatre is worse than nothing, because the presence of a step named ",[38,2942,2943],{},"Check story coverage doc is current"," is read by every engineer who scans that file as a guarantee that the story coverage doc is current.",[11,2946,2947,2948,2951],{},"The first fix was seven lines and it has since merged, as a pull request rather than a paragraph, which is the point we have made before about turning ",[14,2949,2950],{"href":2254},"a convention into a constraint",". It went in as two commits, red then green: first the gate on its own, failing honestly against the stale document, and then the regeneration that turned it green. That is the discipline we use for a bug fix, and a pipeline deserves it as much as a function does. The spec check is following behind it.",[11,2953,2954],{},"The lesson generalises past our repository, and it is worth carrying to yours. Go and read your pipeline to the bottom. For every step that reports something, find the line that acts on the report. Where there is no such line, you have a check that is describing your codebase rather than defending it, and you have been trusting it for exactly as long as it has been lying. Then go and look for the artefacts nobody checks at all, the committed schema, the generated client, the lockfile. Those do not even have a step to be suspicious of.",[23,2956,2958],{"id":2957},"what-green-has-to-mean","What green has to mean",[11,2960,2961,2962,2965],{},"Underneath all of this sits a definition. Green must mean ",[340,2963,2964],{},"this commit could be deployed",". Not \"the tests we happened to run passed\". Not \"nothing obviously exploded\".",[11,2967,2968,2969,2971,2972,2974],{},"Because the instant green means anything less than that, every downstream thing that depends on green, the deploy, the release, the confidence of the next person to pull, is resting on a claim nobody has checked. Our deploy workflow fires only when ",[38,2970,2749],{}," concludes successfully. That is a chain of trust, and it is exactly as strong as the weakest ",[38,2973,2852],{}," step at the bottom of a file nobody reads.",[11,2976,2977],{},"That chain, and what it takes to make it worth trusting, is where this series ends.",[335,2979],{},[11,2981,2982,343,2984],{},[340,2983,342],{},[340,2985,346],{},[348,2987,2988],{},"html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":36,"searchDepth":62,"depth":62,"links":2990},[2991,2992,2993,2994],{"id":2708,"depth":62,"text":2709},{"id":2721,"depth":62,"text":2722},{"id":2740,"depth":62,"text":2741},{"id":2957,"depth":62,"text":2958},"2026-07-09T09:54:00.000Z","If the software worked five minutes ago, only five minutes of change can be to blame, and debugging turns into reading. That is what a green trunk buys. It is bought by agreement, not by tooling, and one of our own checks turns out to be incapable of failing.",{},{"title":2687,"description":2996},"blog\u002Fnever-break-the-build",[371,372,3001],"testing","WF2QB3m3ymlmvrAISX8J1LaKV5gtV-QJ6hB79e9tWaM",{"id":3004,"title":3005,"author":6,"body":3006,"category":359,"date":3289,"description":3290,"draft":362,"extension":363,"image":364,"meta":3291,"navigation":178,"path":3292,"project":367,"readingMinutes":97,"seo":3293,"stem":3294,"tags":3295,"__hash__":3296},"blog\u002Fblog\u002Fready-to-ship-not-ready-to-release.md","Ready to Ship, Not Ready to Release",{"type":8,"value":3007,"toc":3281},[3008,3011,3014,3017,3022,3026,3033,3038,3043,3046,3050,3053,3056,3059,3063,3066,3150,3153,3162,3168,3177,3183,3187,3190,3193,3199,3202,3208,3211,3218,3225,3228,3232,3235,3247,3250,3254,3257,3264,3267,3270,3272,3278],[11,3009,3010],{},"Ask a team whether a feature is done and they will say yes. Ask when it can be in front of users and the answer grows qualifiers. There is the migration to write. Someone has to update the environment variables. The installer has not been rebuilt since March. The manual is out of date. Nobody has run it against a database with real data in it.",[11,3012,3013],{},"This gap, between the moment the work is called done and the moment it could actually reach a user, is where software projects quietly lose weeks. It is invisible while you are in it, because none of the tasks in it are hard. They are small, dull, and unaccounted for, and they are discovered rather than planned. By the time the team is doing them they are also under pressure, so they do them by hand, skip the automation that would have made them repeatable, and arrive at the next release having learned nothing.",[11,3015,3016],{},"The goal that closes the gap is a strange one to state, and it is the destination of this whole series.",[11,3018,3019],{},[413,3020,3021],{},"You should be able to deploy everything except the last few hours of work, at any moment, without preparation.",[23,3023,3025],{"id":3024},"two-kinds-of-ready","Two kinds of ready",[11,3027,3028,3029,3032],{},"That sounds like it demands finished features. It does not, and the distinction we drew when talking about ",[14,3030,3031],{"href":529},"branches as inventory"," is the one that makes it achievable.",[11,3034,441,3035,3037],{},[413,3036,444],{}," means the feature is complete and worth showing to someone. Halfway through a week's work, it is not, and no discipline can make it so.",[11,3039,441,3040,3042],{},[413,3041,448],{}," means the trunk builds, the tests pass, the migrations apply, the deploy pipeline works, and shipping this commit would produce a running system. That has nothing to do with whether the quota feature is finished. It is a property of the machinery, and the machinery can be kept in that condition permanently.",[11,3044,3045],{},"You will not actually release in the middle of an iteration, because half the stories are half built. The point is that nothing technical stands in the way. Releasing becomes a business decision rather than an engineering event. And the way you get there is not by preparing carefully before each release. It is by making every integration resemble a release so closely that the real one is unremarkable.",[23,3047,3049],{"id":3048},"grow-the-release-machinery-with-the-product","Grow the release machinery with the product",[11,3051,3052],{},"The consequence is that release infrastructure is not a phase. It is not something a team builds once the product works. It is product code, built alongside the feature code from the first story, tested, refactored, and reviewed like anything else.",[11,3054,3055],{},"The first story on a new project should ship. Not to users, necessarily, but through the whole pipeline: built, tested, packaged, deployed somewhere real. When that exists on day one, it stays working, because it is exercised dozens of times a day and breaks loudly when it stops. When it is deferred, it is built in a hurry by someone who is already late, and it never becomes trustworthy, because the only way to trust a deployment is to have done it a thousand times.",[11,3057,3058],{},"This is the same principle as a test suite. A suite written after the code tests what the code does. A pipeline built after the product deploys whatever the product happens to be.",[23,3060,3062],{"id":3061},"deploying-the-commit-you-tested","Deploying the commit you tested",[11,3064,3065],{},"Here is a piece of our own machinery that took a while to get right, and which encodes more of this argument than its size suggests.",[31,3067,3069],{"className":33,"code":3068,"language":35,"meta":36,"style":36},"on:\n  workflow_run:\n    workflows: [\"DORA Metrics\"]\n    types: [completed]\n    branches: [main]\n\njobs:\n  deploy-server:\n    if: github.event.workflow_run.conclusion == 'success'\n",[38,3070,3071,3079,3086,3100,3112,3123,3127,3134,3141],{"__ignoreMap":36},[41,3072,3073,3076],{"class":43,"line":44},[41,3074,3075],{"class":105},"on",[41,3077,3078],{"class":47},":\n",[41,3080,3081,3084],{"class":43,"line":62},[41,3082,3083],{"class":51},"  workflow_run",[41,3085,3078],{"class":47},[41,3087,3088,3091,3094,3097],{"class":43,"line":73},[41,3089,3090],{"class":51},"    workflows",[41,3092,3093],{"class":47},": [",[41,3095,3096],{"class":58},"\"DORA Metrics\"",[41,3098,3099],{"class":47},"]\n",[41,3101,3102,3105,3107,3110],{"class":43,"line":85},[41,3103,3104],{"class":51},"    types",[41,3106,3093],{"class":47},[41,3108,3109],{"class":58},"completed",[41,3111,3099],{"class":47},[41,3113,3114,3117,3119,3121],{"class":43,"line":91},[41,3115,3116],{"class":51},"    branches",[41,3118,3093],{"class":47},[41,3120,789],{"class":58},[41,3122,3099],{"class":47},[41,3124,3125],{"class":43,"line":97},[41,3126,179],{"emptyLinePlaceholder":178},[41,3128,3129,3132],{"class":43,"line":1358},[41,3130,3131],{"class":51},"jobs",[41,3133,3078],{"class":47},[41,3135,3136,3139],{"class":43,"line":1390},[41,3137,3138],{"class":51},"  deploy-server",[41,3140,3078],{"class":47},[41,3142,3143,3145,3147],{"class":43,"line":1407},[41,3144,1410],{"class":51},[41,3146,55],{"class":47},[41,3148,3149],{"class":58},"github.event.workflow_run.conclusion == 'success'\n",[11,3151,3152],{},"Two things are happening. The deploy is chained to the test run rather than to the push, so nothing reaches production that has not been proven green. And then the deploy triggers on the specific commit that was tested:",[31,3154,3156],{"className":33,"code":3155,"language":35,"meta":36,"style":36},"hoststack deploy trigger svc_... --commit ${{ github.event.workflow_run.head_sha }}\n",[38,3157,3158],{"__ignoreMap":36},[41,3159,3160],{"class":43,"line":44},[41,3161,3155],{"class":58},[11,3163,3164,3165,3167],{},"Not the tip of ",[38,3166,789],{},". The head of the workflow run that just passed.",[11,3169,3170,3171,3173,3174,3176],{},"The difference matters on exactly the day you would least like it to. Suppose two commits land within a few minutes. The first goes green and its deploy begins. In the seconds between, the second commit lands on ",[38,3172,789],{},". A pipeline that deploys \"the latest ",[38,3175,789],{},"\" now ships a commit whose tests have not finished, on the authority of a green tick that belonged to a different commit. It will work almost every time, and the once it does not, the deploy log will show a green build next to a broken production, and you will not believe your own eyes.",[11,3178,3179,3180],{},"Deploy the artefact you tested. It is the same instinct as never repairing the build by hand on the integration machine: ",[413,3181,3182],{},"do not let the thing you verified and the thing you ship drift apart, even by a few seconds.",[23,3184,3186],{"id":3185},"release-infrastructure-is-a-design-problem","Release infrastructure is a design problem",[11,3188,3189],{},"The other half of our deploy configuration is not a workflow at all. It is a decision, written down where the next person will find it.",[11,3191,3192],{},"Automatic deployment is switched off for the server on our host. That sounds like a regression until you know why. Auto deploy is also what produces preview environments for pull requests, and on our platform a preview inherits the production environment, including the link to the production database. A branch containing a migration would therefore run that migration against production the moment its preview booted, before anyone had reviewed a line of it.",[11,3194,3195,3196,3198],{},"So the server has exactly one deploy path, the workflow above, and it fires only for commits on ",[38,3197,789],{}," whose tests are green.",[11,3200,3201],{},"The web service keeps its automatic deployment, because it is a static site that reaches no database and its previews are harmless. That decision has a consequence which took us an embarrassingly long time to say out loud.",[11,3203,3204,3205,3207],{},"Our test workflow takes about three minutes. Automatic deployment ships the frontend the moment a commit lands on ",[38,3206,789],{},". So on every single push, for roughly three minutes, the frontend serving real users is a commit whose tests have not finished running. If it is broken, the tests will say so, and they will say so to a site that is already broken.",[11,3209,3210],{},"Read honestly, then, the pipeline guarded half the system. The server waited for green. The frontend never did, and the diagram on the wall did not distinguish between them.",[11,3212,3213,3214,3217],{},"Continuous integration cannot gate what has already gone out. So the checks that protect the frontend were moved into the one place that still stands between a commit and a running site: the build stage of its ",[38,3215,3216],{},"Dockerfile",". A failing type check there produces no image, and no image means no deploy.",[11,3219,3220,3221,3224],{},"Read that again, because it inverts the usual instinct. When the pipeline could not be made to refuse, the refusal was pushed down into the artefact itself. The gate did not become a policy, or a note in a document, or a rule someone has to remember during review. It became a property of the thing being built, which is the same move as ",[14,3222,3223],{"href":2254},"turning a convention into a plugin",", applied to a deployment.",[11,3226,3227],{},"The point is not the specific configuration. It is that this reasoning exists, is written in a comment above the code that enforces it, and was arrived at deliberately rather than after an incident. That is what it means to treat release infrastructure as part of the product: the tricky bits get designed, the design gets recorded, and it is reviewed like anything else.",[23,3229,3231],{"id":3230},"the-last-mile-is-still-not-free","The last mile is still not free",[11,3233,3234],{},"Being technologically ready to deploy is not the same as being correct once deployed, and it would be dishonest to end a series on a note of triumph about pipelines.",[11,3236,3237,3238,3241,3242,3246],{},"We have written about the ",[14,3239,3240],{"href":969},"fix that was merged while production kept showing the old numbers",", because derived data is a cache and deploying new code does not refresh it. We have written about ",[14,3243,3245],{"href":3244},"\u002Fblog\u002Fsafe-to-run-twice","operations that must be safe to run twice",", because a deploy that retries, or a migration that runs during a rolling restart, will happen more than once whatever the diagram says. The pipeline gets your code to the machine. It does not make your code true.",[11,3248,3249],{},"What it does is remove the class of problems that come from not having done this recently. And that, in the end, is the thread running through all seven of these posts.",[23,3251,3253],{"id":3252},"the-thread","The thread",[11,3255,3256],{},"We began with a conflict in a file nobody wrote, produced by two branches that had been apart for a single day. Along the way: your local trunk is a photograph of the real one; the rebase and merge argument is loud because the branches are old; a branch is inventory and depreciates; a server that runs your tests is not the practice of integrating; a green trunk is valuable because it shrinks the search; a check that cannot fail is theatre.",[11,3258,3259,3260,3263],{},"Every one of those is the same idea in different clothing. ",[413,3261,3262],{},"Distance from the trunk is the cost, and every practice here is a way of paying it early, in small amounts, when it is cheap."," The conflict, the stale cache, the broken build, the frightening release, the derived data that lied: all of them are what deferred integration looks like at the moment it finally presents its bill.",[11,3265,3266],{},"Integrate every few hours. Never leave it broken. Keep the machinery that ships it working, always, as part of the work rather than after it.",[11,3268,3269],{},"Then the release, when it comes, is a nonevent. Which is the highest praise available to a deploy.",[335,3271],{},[11,3273,3274,343,3276],{},[340,3275,342],{},[340,3277,346],{},[348,3279,3280],{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s9eBZ, html code.shiki .s9eBZ{--shiki-default:#22863A;--shiki-dark:#85E89D}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":36,"searchDepth":62,"depth":62,"links":3282},[3283,3284,3285,3286,3287,3288],{"id":3024,"depth":62,"text":3025},{"id":3048,"depth":62,"text":3049},{"id":3061,"depth":62,"text":3062},{"id":3185,"depth":62,"text":3186},{"id":3230,"depth":62,"text":3231},{"id":3252,"depth":62,"text":3253},"2026-07-09T10:02:00.000Z","Most teams have a hidden delay between saying done and being able to ship. The goal is to be technologically ready to release at any moment, even when the feature is half built. Why our deploy workflow ships the commit that was tested rather than the newest one.",{},"\u002Fblog\u002Fready-to-ship-not-ready-to-release",{"title":3005,"description":3290},"blog\u002Fready-to-ship-not-ready-to-release",[371,973,372],"pZpUigJFVngRba26WU8zEM4UYBmx5VAjqt_NZHq9uVc",{"id":3298,"title":3299,"author":6,"body":3300,"category":525,"date":3452,"description":3453,"draft":362,"extension":363,"image":364,"meta":3454,"navigation":178,"path":3455,"project":367,"readingMinutes":97,"seo":3456,"stem":3457,"tags":3458,"__hash__":3459},"blog\u002Fblog\u002Frebase-or-merge-is-the-wrong-argument.md","Rebase or Merge Is the Wrong Argument",{"type":8,"value":3301,"toc":3445},[3302,3305,3308,3312,3319,3322,3325,3329,3335,3341,3348,3354,3357,3363,3367,3370,3381,3388,3391,3395,3398,3401,3409,3412,3415,3419,3422,3425,3428,3431,3437,3439],[11,3303,3304],{},"Ask a room of engineers whether to rebase a branch or merge the trunk into it and you will get a debate with the emotional register of a religious schism. Ask the same room how old their branches are and the room goes quiet.",[11,3306,3307],{},"The two questions are related, and only one of them is worth the argument.",[23,3309,3311],{"id":3310},"they-do-the-same-arithmetic","They do the same arithmetic",[11,3313,3314,3315,3318],{},"Both commands perform a three way merge. Git finds the most recent commit the two lines of work have in common, the ",[413,3316,3317],{},"merge base",", and then compares each side against it. A line changed on one side and untouched on the other is taken. A line changed identically on both is taken once. A line changed differently on both is a conflict, and git stops and asks.",[11,3320,3321],{},"That procedure is the same whether you rebase or merge. Neither command is cleverer than the other about text. Neither will spare you a conflict the other would have raised, given the same base and the same tips. Anyone who tells you that rebasing \"avoids conflicts\" has been getting away with short branches.",[11,3323,3324],{},"What the two commands differ on is the shape of the result, and that turns out to be three separate trade offs wearing one name.",[23,3326,3328],{"id":3327},"what-actually-differs","What actually differs",[11,3330,3331,3334],{},[413,3332,3333],{},"Where the work ends up."," A rebase takes your commits, sets them aside, moves your branch to the trunk's tip, and replays them one at a time. The commits that come out are not the commits that went in. They have the same changes and different identities. A merge leaves your commits exactly where they were and adds one new commit whose job is to record that two lines of history came together.",[11,3336,3337,3340],{},[413,3338,3339],{},"When you resolve."," Because a rebase replays commit by commit, a conflict is raised once for every one of your commits that touches the contested file. Because a merge reconciles two tips, it is raised once, full stop.",[11,3342,3343,3344,3347],{},"Our own quota branch had three commits: a failing test, the implementation that made it pass, and a regeneration of the behaviour document. Only the third touched the document that conflicted, so the rebase asked once and it happened to cost nothing extra. Had we been less lucky, and the file been touched in all three, we would have resolved the same conflict three times, watching our own edits arrive back in front of us like a bad dream. Git has a facility for exactly this, ",[38,3345,3346],{},"git rerere",", which records how you resolved a conflict and replays your resolution when it recognises the same one again. That it exists at all is a confession about how often people hit this.",[11,3349,3350,3353],{},[413,3351,3352],{},"What you can do afterwards."," New commit identities mean the branch cannot be pushed normally, only force pushed, because the remote branch and your local one no longer share a tip. That is fine on a branch nobody else has. It is destructive on a branch somebody else has pulled, because their next pull will produce a snarl of duplicated commits.",[11,3355,3356],{},"That gives us the one rule in this whole area that admits no exceptions and needs no judgement.",[11,3358,3359,3362],{},[413,3360,3361],{},"Never rewrite history that other people have."," Everything else is preference. This is not.",[23,3364,3366],{"id":3365},"what-history-is-for","What history is for",[11,3368,3369],{},"Underneath the tooling sits a question people rarely ask out loud: what do you want your history to be able to answer later?",[11,3371,3372,3373,3376,3377,3380],{},"A rebased history is a story about ",[413,3374,3375],{},"what the code became",". It is linear. Every commit sits on top of a trunk that once existed, ",[38,3378,3379],{},"git bisect"," walks it without ambiguity, and reading it feels like reading a narrative someone edited for clarity, because it is.",[11,3382,3383,3384,3387],{},"A merged history is a record of ",[413,3385,3386],{},"what actually happened",". It preserves the fact that this work was developed alongside that work, and that on Thursday they were reconciled. It is truer and it is harder to read, and on a busy repository the graph turns into cable spaghetti.",[11,3389,3390],{},"Neither is dishonest. They answer different questions. Choose by which question your team asks more often, and notice that the answer usually correlates with how many people touch the repository at once.",[23,3392,3394],{"id":3393},"the-option-that-quietly-destroys-evidence","The option that quietly destroys evidence",[11,3396,3397],{},"There is a third button on the forge, and it deserves naming because it is the default in many teams and it is not neutral.",[11,3399,3400],{},"A squash merge takes every commit on your branch, flattens them into one, and puts that on the trunk. The branch's internal history is gone.",[11,3402,3403,3404,3408],{},"For a branch of six commits named \"wip\", \"fix\", \"fix again\", that is a mercy. But we work in a way where the history of a branch carries information we deliberately put there. A fix arrives as a pair of commits: first the failing test, committed red, then the code that turns it green. That pairing is the evidence that the test was written before the code and that it genuinely failed without it, which is the only thing separating ",[14,3405,3407],{"href":3406},"\u002Fblog\u002Ftest-driven-development-is-not-about-tests","test driven development"," from writing tests afterwards and hoping.",[11,3410,3411],{},"Squash that branch and the pair becomes one commit containing a test and its implementation, indistinguishable from the work of someone who wrote the code first and reverse engineered a test to cover it. The claim survives. The proof does not.",[11,3413,3414],{},"So the squash question is not about tidiness either. It is: does the sequence of your commits mean something? If it does, do not flatten it. If it does not, ask why you are producing commits that mean nothing.",[23,3416,3418],{"id":3417},"why-the-argument-is-loud","Why the argument is loud",[11,3420,3421],{},"Here is the thing worth taking away. Every cost above scales with divergence.",[11,3423,3424],{},"The number of times a rebase asks about the same conflict scales with how many of your commits touched the file. Whether a force push endangers anyone scales with how long the branch has been public. Whether the merge graph becomes unreadable scales with how many branches were open at once. Whether the conflict is hard scales with how far the trunk moved while you were away.",[11,3426,3427],{},"Shrink the divergence and every one of these shrinks with it. On a branch that is four hours old and three commits long, rebase and merge produce the same practical outcome in the same amount of time, and the choice really is a matter of taste. On a branch that is three weeks old, the choice is a decision about how much pain to take and in what shape, and the team argues about it fiercely because it hurts, and because arguing about the tool is more comfortable than admitting that the branch should never have been allowed to get that old.",[11,3429,3430],{},"The practice that dissolves the argument is the one this series is about: bring the trunk into your work every few hours, so that there is never much to reconcile, and so the reconciliation is boring however you spell it.",[11,3432,3433,3434,3436],{},"Our recommendation for the Dojo, where a slice of work lives for a day at most and belongs to one person, is to rebase onto ",[38,3435,2305],{}," and force push the branch, keeping the trunk linear and the red and green pair intact. That recommendation is worth exactly as much as the premise underneath it. On the day a branch of ours lives for three weeks, the right answer will change, and the right response will not be to change the answer.",[335,3438],{},[11,3440,3441,343,3443],{},[340,3442,342],{},[340,3444,346],{},{"title":36,"searchDepth":62,"depth":62,"links":3446},[3447,3448,3449,3450,3451],{"id":3310,"depth":62,"text":3311},{"id":3327,"depth":62,"text":3328},{"id":3365,"depth":62,"text":3366},{"id":3393,"depth":62,"text":3394},{"id":3417,"depth":62,"text":3418},"2026-07-09T09:30:00.000Z","Both commands ask git to reconcile the same three commits, and both produce the same conflicts. What differs is when you resolve them, what history remembers, and whether you can force push. The argument is loud because the real problem, branches that live too long, is quiet.",{},"\u002Fblog\u002Frebase-or-merge-is-the-wrong-argument",{"title":3299,"description":3453},"blog\u002Frebase-or-merge-is-the-wrong-argument",[533,371,373],"zoPWJBosw0xTQq_4G_FCgPkBNtTesYCOLHjCA3y_OCE",{"id":3461,"title":3462,"author":6,"body":3463,"category":3557,"date":3558,"description":3559,"draft":362,"extension":363,"image":364,"meta":3560,"navigation":178,"path":3561,"project":367,"readingMinutes":73,"seo":3562,"stem":3563,"tags":3564,"__hash__":3567},"blog\u002Fblog\u002Freconstructing-the-past.md","Reconstructing the Past",{"type":8,"value":3464,"toc":3552},[3465,3476,3480,3487,3494,3509,3513,3527,3530,3540,3542,3549],[11,3466,3467,3468,3471,3472,3475],{},"You connect a repository that has a year of history behind it. The platform was not watching when those pull requests were opened and merged, it had never heard of this repo until a moment ago. Yet the moment you connect it, a profile fills in and a trend chart draws a curve reaching back months. How does a system show you a past it never witnessed? Two moves: ",[413,3469,3470],{},"onboarding"," turns strangers into records, and ",[413,3473,3474],{},"backfill"," replays history into the shape the dashboards expect.",[23,3477,3479],{"id":3478},"onboarding-strangers-become-records","Onboarding: strangers become records",[11,3481,3482,3483,3486],{},"The repository's pull requests were written by people, identified by their GitHub usernames. The platform attributes work to ",[340,3484,3485],{},"participants",", its own internal records. So the first job is to bridge the two: for each pull request author, is this someone we already know?",[11,3488,3489,3490,3493],{},"The importer holds a map from GitHub username to participant, and for each author it does a ",[413,3491,3492],{},"match or create",": if the username is already a participant, attribute the PR to them; if not, and they are a real person (bots are skipped), create a participant so the work has somewhere to land. This is a pattern you will meet everywhere data crosses a boundary: an external identity (a GitHub login, an email, a customer id) has to be resolved to an internal one, and you either find the existing record or mint a new one.",[11,3495,3496,3497,3500,3501,3504,3505,3508],{},"There is a deliberate choice hiding here. When ",[340,3498,3499],{},"you"," connect ",[340,3502,3503],{},"your own"," repo on the free tier, the importer runs with onboarding turned off, so it attributes pull requests to people already on the platform but does not create accounts for every stranger who ever contributed. Connecting your side project should show ",[340,3506,3507],{},"your"," stats, not silently enrol a dozen people who never asked to be here. The same code, one flag, two very different social contracts.",[23,3510,3512],{"id":3511},"backfill-replaying-history-into-snapshots","Backfill: replaying history into snapshots",[11,3514,3515,3516,3519,3520,3522,3523,3526],{},"Now the harder half. The trend chart is not drawn from raw pull requests; it is drawn from ",[413,3517,3518],{},"weekly snapshots",", one row per developer per week holding that week's merge rate, throughput and the rest. During normal operation those snapshots are written as events arrive. But the history predates us, so there are no snapshots for it. We have to ",[413,3521,3474],{},": compute what each week's snapshot ",[340,3524,3525],{},"would have been"," had we been watching.",[11,3528,3529],{},"So the importer walks each active developer week by week, and for every week they opened at least one pull request, it computes that week's snapshot from the pull requests that fall in it. Empty weeks are skipped, so the trend is made of real points rather than a run of zeroes. The result is a curve that looks exactly as if the platform had been recording all along, reconstructed from the one source of truth it does have: the pull requests themselves.",[11,3531,3532,3535,3536,3539],{},[413,3533,3534],{},"How far back?"," Deliberately bounded. The pull requests are all imported, the whole history. But the weekly snapshots reach back at most ",[413,3537,3538],{},"one year"," (52 weeks) from a developer's earliest pull request. A developer with three years of history gets every PR stored, and a trend covering their most recent year. The bound is a choice: enough history to be useful, not so much that a single connect drags the database through a decade of dead weeks.",[23,3541,933],{"id":932},[11,3543,3544,3545,3548],{},"Backfill is a specific idea worth naming: ",[413,3546,3547],{},"derived state can be recomputed from the source of truth at any time."," The snapshots are not precious; they are a function of the pull requests. Because that function exists, a repo we met five seconds ago can have a year of trend, and if we ever change how a snapshot is computed, we can rebuild every one of them.",[11,3550,3551],{},"The mindset: keep a clear source of truth, keep derived data honestly derived, and you can always reconstruct the past, whether you were watching when it happened or not. That is also why \"connect and see your history in minutes\" is a feature we can actually promise.",{"title":36,"searchDepth":62,"depth":62,"links":3553},[3554,3555,3556],{"id":3478,"depth":62,"text":3479},{"id":3511,"depth":62,"text":3512},{"id":932,"depth":62,"text":933},"Design and Architecture","2026-07-07T21:26:35.000Z","Connect a repository with a year of history and a profile fills in and a trend curve reaches back months. How a system shows a past it never witnessed: onboarding turns external identities into records, and backfill recomputes history from the one source of truth it has.",{},"\u002Fblog\u002Freconstructing-the-past",{"title":3462,"description":3559},"blog\u002Freconstructing-the-past",[3565,3566,3470],"design","data","gjZ9o_qDKA3CfOS7Mmj4lsL-x5pDtz6JbluTTsR9_Rk",{"id":3569,"title":3570,"author":6,"body":3571,"category":3906,"date":3907,"description":3908,"draft":362,"extension":363,"image":364,"meta":3909,"navigation":178,"path":3910,"project":367,"readingMinutes":91,"seo":3911,"stem":3912,"tags":3913,"__hash__":3916},"blog\u002Fblog\u002Fretrofitting-testing-without-mocks.md","Retrofitting Testing Without Mocks",{"type":8,"value":3572,"toc":3898},[3573,3580,3583,3587,3601,3608,3612,3617,3628,3633,3661,3679,3684,3709,3718,3723,3744,3749,3756,3760,3769,3862,3865,3869,3875,3879,3885,3889,3895],[11,3574,3575,3576,3579],{},"The server had 37 instances of ",[38,3577,3578],{},"vi.mock"," spread across three test files. Prisma was mocked, the WakaTime HTTP client was hidden inside a private function, the GitHub handler tests asserted on mock call counts, and the integration test for the webhooks route was mocking four handlers to avoid touching the database. None of the tests exercised the database, none of them would have caught a broken Prisma query, and the whole scaffolding broke quietly whenever anyone refactored a function signature.",[11,3581,3582],{},"This is the state that Shore's Nullables pattern was invented to fix. We retrofitted it over five small steps. This post is the walkthrough.",[23,3584,3586],{"id":3585},"where-we-started","Where we started",[11,3588,3589,3590,3593,3594,3596,3597,3600],{},"Four test files in ",[38,3591,3592],{},"server\u002Ftest\u002F",". Three used ",[38,3595,3578],{}," to replace Prisma, the XP service, the repo service, and the GitHub event handlers. The only pure file was ",[38,3598,3599],{},"signature.test.ts",", which worked only because HMAC verification is arithmetic.",[11,3602,3603,3604,3607],{},"We also had a teaching kata at ",[38,3605,3606],{},"katas\u002Fwakatime\u002F"," that had already done the pure function extraction on paper. It described the shape of the pattern without applying it to the production code.",[23,3609,3611],{"id":3610},"the-five-steps","The five steps",[11,3613,3614],{},[413,3615,3616],{},"Step 1: extract the pure logic.",[11,3618,3619,3620,3623,3624,3627],{},"The WakaTime service had about 80 lines of arithmetic tangled with Prisma reads and ",[38,3621,3622],{},"awardXP"," calls. We lifted four pure functions into ",[38,3625,3626],{},"server\u002Fsrc\u002Fservices\u002Fwakatime-logic.ts",": XP calculation, editor breakdown parsing, streak counting, and language aggregation. 15 unit tests against the new module run in 5 milliseconds and touch nothing external. The original service imports and delegates.",[11,3629,3630],{},[413,3631,3632],{},"Step 2: set up a real test database.",[11,3634,3635,3636,3639,3640,3643,3644,3646,3647,3649,3650,3652,3653,3656,3657,3660],{},"The existing Docker Postgres on 5433 got a second database called ",[38,3637,3638],{},"dojo_test",". A setup script at ",[38,3641,3642],{},"server\u002Fscripts\u002Fsetup-test-db.sh"," drops and recreates it, then runs ",[38,3645,856],{},". Vitest gets a ",[38,3648,302],{}," pointed at ",[38,3651,3638],{}," via the config. A ",[38,3654,3655],{},"resetDb"," helper truncates every application table between tests. An ",[38,3658,3659],{},"assertTestDatabase"," guard in the same file throws if the URL ever drifts back to the main database.",[11,3662,3663,3664,3667,3668,3671,3672,3675,3676,3678],{},"Cleaning this up surfaced a side project: the Prisma migrations folder had seven migrations describing an older schema while the main database had been moved forward via ",[38,3665,3666],{},"prisma db push",". We collapsed the seven into a single ",[38,3669,3670],{},"0_init"," generated from the current ",[38,3673,3674],{},"schema.prisma",", marked it applied on the main DB, and pointed the test setup script at ",[38,3677,901],{},". Both databases now agree with the migration history.",[11,3680,3681],{},[413,3682,3683],{},"Step 3: build the infrastructure wrapper.",[11,3685,3686,3689,3690,3693,3694,3697,3698,3701,3702,3704,3705,3708],{},[38,3687,3688],{},"server\u002Fsrc\u002Finfrastructure\u002Fwakatime-client.ts"," wraps ",[38,3691,3692],{},"fetch"," with two factory methods. ",[38,3695,3696],{},"WakatimeClient.create()"," returns the real client. ",[38,3699,3700],{},"WakatimeClient.createNull({ summaries: [...] })"," returns an instance whose ",[38,3703,3692],{}," is replaced with a scripted stub. Both share the same ",[38,3706,3707],{},"fetchSummary"," method, so parsing, status handling, and error recovery run end to end in tests. Eight tests exercise both modes and run in 7 milliseconds.",[11,3710,3711,3712,3714,3715,3717],{},"The Nullable pattern here is the piece that does the heavy lifting. You do not mock ",[38,3713,3692],{},". You do not wrap the client in a stub object. You replace only the network boundary, inside the real class, by passing a different ",[38,3716,3692],{}," to the constructor. The production code path is fully exercised in tests.",[11,3719,3720],{},[413,3721,3722],{},"Step 4: rewrite the service tests sociably.",[11,3724,3725,3728,3729,3732,3733,3736,3737,794,3740,3743],{},[38,3726,3727],{},"services\u002Fwakatime.ts"," now takes a ",[38,3730,3731],{},"WakatimeClient"," as an optional parameter, defaulting to the real one. Tests pass in a Nullable client configured with a scripted summary and run against the real database. Seven tests cover no API key, no data, sub hour activity, focus hour XP, stacked bonuses, idempotency, and API errors. Each one creates a participant, calls ",[38,3734,3735],{},"syncWakaTimeActivity",", and asserts on rows in ",[38,3738,3739],{},"WakaTimeActivity",[38,3741,3742],{},"XPTransaction",". Zero test doubles, real Prisma, real XP awarding logic.",[11,3745,3746],{},[413,3747,3748],{},"Step 5: rewrite the GitHub handler tests.",[11,3750,3751,3752,3755],{},"Three test files, one at a time. Each one seeds a participant and whatever prerequisite rows the handler reads, calls the handler directly, and asserts on database state after. The push handler test verifies a ",[38,3753,3754],{},"GitMetric"," is created and the repo commit count increments. The check run handler test verifies a pending metric flips to green and the participant streak goes up. The webhooks route integration test hits the full stack with a signed payload and asserts that routing, signature verification, raw body handling, and handler execution all compose correctly against the real database.",[23,3757,3759],{"id":3758},"what-came-out-the-other-side","What came out the other side",[11,3761,3762,3768],{},[413,3763,3764,3765,3767],{},"Zero instances of ",[38,3766,3578],{}," anywhere in the test directory."," An ESLint rule banning it would be enforceable without any follow up cleanup.",[2032,3770,3771,3782],{},[2035,3772,3773],{},[2038,3774,3775,3778,3780],{},[2041,3776,3777],{},"Metric",[2041,3779,1249],{},[2041,3781,1604],{},[2052,3783,3784,3797,3809,3820,3831,3841,3851],{},[2038,3785,3786,3791,3794],{},[2057,3787,3788,3790],{},[38,3789,3578],{}," usages",[2057,3792,3793],{},"37",[2057,3795,3796],{},"0",[2038,3798,3799,3804,3807],{},[2057,3800,3801,3790],{},[38,3802,3803],{},"vi.mocked",[2057,3805,3806],{},"dozens",[2057,3808,3796],{},[2038,3810,3811,3814,3817],{},[2057,3812,3813],{},"Total tests",[2057,3815,3816],{},"44",[2057,3818,3819],{},"70",[2038,3821,3822,3825,3828],{},[2057,3823,3824],{},"Pure logic tests",[2057,3826,3827],{},"9",[2057,3829,3830],{},"32",[2038,3832,3833,3836,3838],{},[2057,3834,3835],{},"Infrastructure tests",[2057,3837,3796],{},[2057,3839,3840],{},"8",[2038,3842,3843,3846,3848],{},[2057,3844,3845],{},"Sociable integration tests",[2057,3847,3796],{},[2057,3849,3850],{},"30",[2038,3852,3853,3856,3859],{},[2057,3854,3855],{},"Suite wall clock",[2057,3857,3858],{},"1.2s",[2057,3860,3861],{},"11.9s",[11,3863,3864],{},"Those numbers are from running the full vitest suite three times on each commit. The 11 seconds is worth naming. It is slower than the previous suite because most of the new tests touch Postgres. A handler runs, a few rows get inserted, the next test truncates. Each round trip costs maybe a hundred milliseconds. The payoff is that every one of those tests catches a broken Prisma query, a broken schema assumption, a broken migration, or a broken service boundary. The old suite caught none of those. The new suite caught three already, as a side effect of the retrofit itself.",[23,3866,3868],{"id":3867},"the-pattern-in-one-paragraph","The pattern, in one paragraph",[11,3870,3871,3872,756],{},"Pull pure logic out of side effect laden services. Unit test it directly, no doubles. Wrap each external service in a thin class with a real factory and a Nullable factory. The Nullable replaces only the boundary, usually by passing a scripted stub into the real class, so the rest of the code runs the same way in tests and production. Write application tests sociably, against a real database in a test instance, with Nullable wrappers plugged in. Nothing in your test directory should be the word ",[38,3873,3874],{},"mock",[23,3876,3878],{"id":3877},"what-it-cost","What it cost",[11,3880,3881,3882,3884],{},"A ",[38,3883,3655],{}," helper and a small setup script. One extra database on the same Postgres container. One configuration line turning off parallel file execution, because tests sharing a database cannot safely run concurrently. About ten additional seconds on the test suite. One afternoon of retrofitting.",[23,3886,3888],{"id":3887},"what-it-buys-going-forward","What it buys going forward",[11,3890,3891,3892,3894],{},"Refactors stop breaking test setups. Renaming a Prisma field fails the test that was relying on it, at the right layer, with a real error message. Adding a new handler is a copy of the push handler test with a different payload and different assertions, not a copy of a dozen ",[38,3893,3578],{}," lines with slightly different shapes. Teaching new joiners the pattern is a half hour kata, not a week of debugging why their mock fell out of sync with the service.",[11,3896,3897],{},"We have been telling workshop cohorts to stop mocking for two years. This is the first version of our own repository that walks the talk.",{"title":36,"searchDepth":62,"depth":62,"links":3899},[3900,3901,3902,3903,3904,3905],{"id":3585,"depth":62,"text":3586},{"id":3610,"depth":62,"text":3611},{"id":3758,"depth":62,"text":3759},{"id":3867,"depth":62,"text":3868},{"id":3877,"depth":62,"text":3878},{"id":3887,"depth":62,"text":3888},"Testing and TDD","2026-04-17T12:30:37.000Z","The server had 37 vi.mock calls and not one test that touched the database. Five steps to remove every mock with Shore's Nullables pattern: extract the pure logic, stand up a real test database, wrap each service with real and Null factories, and test sociably.",{},"\u002Fblog\u002Fretrofitting-testing-without-mocks",{"title":3570,"description":3908},"blog\u002Fretrofitting-testing-without-mocks",[3001,3914,3915],"nullables","mocks","5zbIyIFr8E3hRJdiqgzkLZ0jQ3bBGxk5C7WIOLGA-ik",{"id":3918,"title":3919,"author":6,"body":3920,"category":359,"date":3558,"description":4143,"draft":362,"extension":363,"image":364,"meta":4144,"navigation":178,"path":3244,"project":367,"readingMinutes":73,"seo":4145,"stem":4146,"tags":4147,"__hash__":4149},"blog\u002Fblog\u002Fsafe-to-run-twice.md","Safe to Run Twice",{"type":8,"value":3921,"toc":4138},[3922,3928,3932,3954,4001,4014,4018,4025,4031,4102,4109,4111,4114,4128,4135],[11,3923,3924,3925,3927],{},"Here is a question worth sitting with. What happens if you connect the same repository twice? Maybe the user clicks \"Connect\" twice because the first click felt slow. Maybe the network hiccuped and the request was retried. Maybe a webhook redelivered an event GitHub already sent. In a naive system, the second run duplicates every pull request and doubles everyone's XP. In ours, nothing bad happens: run it again and the result is the same. That property has a name, ",[413,3926,974],{},", and the importer earns it with two different techniques for two different kinds of work.",[23,3929,3931],{"id":3930},"the-write-upsert","The write: upsert",[11,3933,3934,3935,3938,3939,3942,3943,794,3946,3949,3950,3953],{},"When the importer processes a pull request, it does not blindly ",[38,3936,3937],{},"create"," a row. It ",[413,3940,3941],{},"upserts",". Upsert is a portmanteau of ",[340,3944,3945],{},"update",[340,3947,3948],{},"insert",": \"if a row like this already exists, update it in place; otherwise create it.\" The trick is choosing the right key to decide \"a row like this\". Ours is the pair ",[38,3951,3952],{},"(repoId, githubId)",", the repo plus GitHub's own PR number, which is unique and stable. So on a reimport, PR #42 does not become a second PR #42; it finds the existing row and refreshes it.",[31,3955,3957],{"className":133,"code":3956,"language":135,"meta":36,"style":36},"await prisma.pullRequest.upsert({\n  where: { repoId_githubId: { repoId, githubId: pull.number } },\n  update: { title, state, additions, deletions \u002F* ... *\u002F },\n  create: { \u002F* the full row *\u002F },\n});\n",[38,3958,3959,3973,3978,3987,3997],{"__ignoreMap":36},[41,3960,3961,3964,3967,3970],{"class":43,"line":44},[41,3962,3963],{"class":81},"await",[41,3965,3966],{"class":47}," prisma.pullRequest.",[41,3968,3969],{"class":1265},"upsert",[41,3971,3972],{"class":47},"({\n",[41,3974,3975],{"class":43,"line":62},[41,3976,3977],{"class":47},"  where: { repoId_githubId: { repoId, githubId: pull.number } },\n",[41,3979,3980,3983,3985],{"class":43,"line":73},[41,3981,3982],{"class":47},"  update: { title, state, additions, deletions ",[41,3984,1936],{"class":198},[41,3986,1939],{"class":47},[41,3988,3989,3992,3995],{"class":43,"line":85},[41,3990,3991],{"class":47},"  create: { ",[41,3993,3994],{"class":198},"\u002F* the full row *\u002F",[41,3996,1939],{"class":47},[41,3998,3999],{"class":43,"line":91},[41,4000,2019],{"class":47},[11,4002,4003,4004,4006,4007,4010,4011],{},"That one word, ",[38,4005,3969],{},", is why reimporting is safe ",[340,4008,4009],{},"for the data",". The rows converge to the same state no matter how many times you run it. This is the first lesson: ",[413,4012,4013],{},"give your records a natural key and write through it, so a repeated write changes nothing rather than duplicating.",[23,4015,4017],{"id":4016},"the-side-effect-a-guard","The side effect: a guard",[11,4019,4020,4021,4024],{},"But data is not the only thing an import does. When a pull request is merged, we ",[413,4022,4023],{},"award XP",". That is a side effect, and side effects do not get idempotency for free. The upsert protects the PR row, but if the merge handler ran twice it would happily award the merge bonus twice, and someone's score would drift upward every time an event redelivered.",[11,4026,4027,4028],{},"So awarding XP needs its own guard. Before paying the merge bonus, the importer asks the ledger a question: ",[340,4029,4030],{},"has this pull request already earned its merge XP?",[31,4032,4034],{"className":133,"code":4033,"language":135,"meta":36,"style":36},"const alreadyAwarded = await prisma.xPTransaction.findFirst({\n  where: { pullRequestId: prRow.id, reason: 'PR_MERGED' },\n});\nif (!alreadyAwarded) {\n  await awardXP({ \u002F* PR_MERGED, and the right sized bonus *\u002F });\n}\n",[38,4035,4036,4056,4066,4070,4081,4098],{"__ignoreMap":36},[41,4037,4038,4040,4043,4045,4048,4051,4054],{"class":43,"line":44},[41,4039,142],{"class":81},[41,4041,4042],{"class":105}," alreadyAwarded",[41,4044,148],{"class":81},[41,4046,4047],{"class":81}," await",[41,4049,4050],{"class":47}," prisma.xPTransaction.",[41,4052,4053],{"class":1265},"findFirst",[41,4055,3972],{"class":47},[41,4057,4058,4061,4064],{"class":43,"line":62},[41,4059,4060],{"class":47},"  where: { pullRequestId: prRow.id, reason: ",[41,4062,4063],{"class":58},"'PR_MERGED'",[41,4065,1939],{"class":47},[41,4067,4068],{"class":43,"line":73},[41,4069,2019],{"class":47},[41,4071,4072,4074,4076,4078],{"class":43,"line":85},[41,4073,184],{"class":81},[41,4075,1334],{"class":47},[41,4077,1415],{"class":81},[41,4079,4080],{"class":47},"alreadyAwarded) {\n",[41,4082,4083,4086,4089,4092,4095],{"class":43,"line":91},[41,4084,4085],{"class":81},"  await",[41,4087,4088],{"class":1265}," awardXP",[41,4090,4091],{"class":47},"({ ",[41,4093,4094],{"class":198},"\u002F* PR_MERGED, and the right sized bonus *\u002F",[41,4096,4097],{"class":47}," });\n",[41,4099,4100],{"class":43,"line":97},[41,4101,204],{"class":47},[11,4103,4104,4105,4108],{},"The ledger row is not just a record of the payment; it ",[413,4106,4107],{},"is"," the guard. Its existence means \"this already happened, do not do it again.\" This is exactly once from at least once machinery: the event may arrive many times, but the effect lands once.",[23,4110,933],{"id":932},[11,4112,4113],{},"The two halves are the whole point. A robust operation separates the work into:",[631,4115,4116,4122],{},[575,4117,4118,4121],{},[413,4119,4120],{},"Idempotent writes",", made safe by a natural key and an upsert. Run them a hundred times, same result.",[575,4123,4124,4127],{},[413,4125,4126],{},"Side effects",", made safe by an explicit guard that records \"done\" and is checked before acting.",[11,4129,4130,4131,4134],{},"The mindset to carry into your own code: ",[413,4132,4133],{},"assume it will run twice."," Networks retry, users double click, queues redeliver, cron overlaps. If your operation would misbehave on a second run, it has a bug that has not happened yet. Make the writes idempotent, guard the side effects, and you can hand someone a \"Connect\" button without fear of what a nervous second click will do.",[348,4136,4137],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":36,"searchDepth":62,"depth":62,"links":4139},[4140,4141,4142],{"id":3930,"depth":62,"text":3931},{"id":4016,"depth":62,"text":4017},{"id":932,"depth":62,"text":933},"What happens if you connect the same repository twice, or a webhook redelivers an event? Idempotency has two halves: idempotent writes through a natural key and an upsert, and side effects guarded by a ledger row, so at least once delivery lands exactly once.",{},{"title":3919,"description":4143},"blog\u002Fsafe-to-run-twice",[372,974,4148],"webhooks","S-pGtdkkNOm3g7SbvJyjSCrkjdYJhR_kNlQWwju_fCM",{"id":4151,"title":4152,"author":4153,"body":4154,"category":3557,"date":364,"description":4179,"draft":362,"extension":363,"image":364,"meta":4180,"navigation":178,"path":4181,"project":4182,"readingMinutes":44,"seo":4183,"stem":4184,"tags":364,"__hash__":4185},"blog\u002Fblog\u002Fstage-then-transform-why-your-crawler-should-never-touch-production-tables.md","Stage, Then Transform: Why Your Crawler Should Never Touch Production Tables","EkoHacks",{"type":8,"value":4155,"toc":4176},[4156,4160,4166,4169,4172],[4157,4158,4152],"h1",{"id":4159},"stage-then-transform-why-your-crawler-should-never-touch-production-tables",[11,4161,4162,4163],{},"When you pull data from an external source, a public register, a CSV feed, a scraped site, there is one decision that quietly determines whether the pipeline will still be working in six months. It is not “which library”, “which schedule”, or “which database”. It is: ",[413,4164,4165],{},"where does the data land first?",[11,4167,4168],{},"Most pipelines answer that question badly. They crawl a source, massage the rows in-memory, and write straight into the production table the app already reads from. It works on day one. It breaks on day ninety.",[11,4170,4171],{},"This post is the pattern I wish I had internalised before I wrote my first crawler, explained with the pipeline we are building right now at Propi: matching first-time buyers to UK conveyancers using public SRA and Law Society data.",[23,4173,4175],{"id":4174},"the-shortcut-that-bites","The shortcut that bites",{"title":36,"searchDepth":62,"depth":62,"links":4177},[4178],{"id":4174,"depth":62,"text":4175},"When you pull data from an external source, the decision that quietly determines whether the pipeline will still be working in six months is where the data lands first. Most pipelines answer that question badly. Here is the pattern I wish I had internalised before I wrote my first crawler, explained with the pipeline we are building right now at Propi.",{},"\u002Fblog\u002Fstage-then-transform-why-your-crawler-should-never-touch-production-tables","Propi",{"title":4152,"description":4179},"blog\u002Fstage-then-transform-why-your-crawler-should-never-touch-production-tables","FYmfwG9zEsXRb_pZXgE5QwLzGsPZxmH6HfdU4rgFWhE",{"id":4187,"title":4188,"author":6,"body":4189,"category":3906,"date":4208,"description":4209,"draft":362,"extension":363,"image":364,"meta":4210,"navigation":178,"path":3406,"project":364,"readingMinutes":62,"seo":4211,"stem":4212,"tags":364,"__hash__":4213},"blog\u002Fblog\u002Ftest-driven-development-is-not-about-tests.md","Test Driven Development Is Not About Tests",{"type":8,"value":4190,"toc":4206},[4191,4194,4197,4200,4203],[11,4192,4193],{},"TDD is best understood as a design discipline rather than a testing technique. The tests are a valuable byproduct, but the real benefit is how the red, green, refactor cycle shapes your thinking about interfaces, responsibilities, and simplicity.",[11,4195,4196],{},"By forcing you to define behaviour before implementation, TDD pushes you to design from the outside in: you clarify what a unit should do, how it should be used, and what its boundaries are, before worrying about how it works internally. This naturally leads to smaller, more focused functions, clearer contracts, and fewer hidden dependencies.",[11,4198,4199],{},"The discipline of writing only the simplest code to make a failing test pass counteracts the common tendency to over‑engineer and speculate about future requirements. Instead of building speculative abstractions, you evolve the design incrementally, guided by concrete, executable examples of desired behaviour. Refactoring with a safety net of tests then lets you continuously improve structure without fear of breaking existing behaviour.",[11,4201,4202],{},"Objections about TDD being slow or unsuitable for complex systems usually stem from treating it as “tests first” for coverage rather than as a design loop. At different scales you adjust the granularity of tests, from small unit tests to higher‑level acceptance or integration tests, but the core principle remains: clarify intent, then implement, then refine.",[11,4204,4205],{},"Practising TDD on small, well‑bounded problems is the fastest way to internalise this rhythm. Over time, the habit of thinking in terms of observable behaviour, clear interfaces, and minimal implementations carries over even when you are not formally doing TDD. That is why, ultimately, TDD is not about tests; it is about cultivating a deliberate way of thinking before you type.",{"title":36,"searchDepth":62,"depth":62,"links":4207},[],"2026-02-01T09:00:00.000Z","TDD is widely misunderstood. It is not a testing strategy. It is a design tool that helps you write simpler, more focused code by forcing you to think before you type.",{},{"title":4188,"description":4209},"blog\u002Ftest-driven-development-is-not-about-tests","asNC2gYP5H41mAW1DD4jOjbhB8YVhijrAVfPiDh-SN4",{"id":4215,"title":4216,"author":6,"body":4217,"category":3557,"date":4785,"description":4786,"draft":362,"extension":363,"image":364,"meta":4787,"navigation":178,"path":4788,"project":4789,"readingMinutes":91,"seo":4790,"stem":4791,"tags":4792,"__hash__":4796},"blog\u002Fblog\u002Fthe-bug-that-resolved-successfully.md","The Bug That Resolved Successfully",{"type":8,"value":4218,"toc":4777},[4219,4226,4236,4239,4320,4330,4334,4337,4366,4382,4398,4402,4405,4408,4411,4461,4466,4494,4497,4501,4506,4539,4542,4554,4564,4568,4574,4580,4586,4590,4593,4761,4764,4768,4771,4774],[11,4220,4221,4222,4225],{},"Every so often you meet a bug that does you the courtesy of throwing an error. This is not one of those. This one says it is ",[38,4223,4224],{},"ready",", resolves cleanly, logs nothing, and quietly drops every document on the floor.",[11,4227,4228,4229,4231,4232,4235],{},"Here is the shape of it in EkoLite, our small reactive sync layer. You subscribe to a publication, the server streams the matching documents into a local store, a ",[38,4230,4224],{}," promise resolves, and from then on the store stays live as data changes. When you call ",[38,4233,4234],{},"stop()",", late messages should stop touching your store. Simple enough.",[11,4237,4238],{},"So you write the most natural line a developer ever writes. You name the publication for what it means:",[31,4240,4242],{"className":133,"code":4241,"language":135,"meta":36,"style":36},"const store = manager.store('files');\nconst handle = manager.subscribe('recentFiles');\n\nawait handle.ready;        \u002F\u002F resolves, no error\nstore.getById('f1');       \u002F\u002F undefined\n",[38,4243,4244,4266,4287,4291,4301],{"__ignoreMap":36},[41,4245,4246,4248,4251,4253,4256,4259,4261,4264],{"class":43,"line":44},[41,4247,142],{"class":81},[41,4249,4250],{"class":105}," store",[41,4252,148],{"class":81},[41,4254,4255],{"class":47}," manager.",[41,4257,4258],{"class":1265},"store",[41,4260,1314],{"class":47},[41,4262,4263],{"class":58},"'files'",[41,4265,1435],{"class":47},[41,4267,4268,4270,4273,4275,4277,4280,4282,4285],{"class":43,"line":62},[41,4269,142],{"class":81},[41,4271,4272],{"class":105}," handle",[41,4274,148],{"class":81},[41,4276,4255],{"class":47},[41,4278,4279],{"class":1265},"subscribe",[41,4281,1314],{"class":47},[41,4283,4284],{"class":58},"'recentFiles'",[41,4286,1435],{"class":47},[41,4288,4289],{"class":43,"line":73},[41,4290,179],{"emptyLinePlaceholder":178},[41,4292,4293,4295,4298],{"class":43,"line":85},[41,4294,3963],{"class":81},[41,4296,4297],{"class":47}," handle.ready;        ",[41,4299,4300],{"class":198},"\u002F\u002F resolves, no error\n",[41,4302,4303,4306,4309,4311,4314,4317],{"class":43,"line":91},[41,4304,4305],{"class":47},"store.",[41,4307,4308],{"class":1265},"getById",[41,4310,1314],{"class":47},[41,4312,4313],{"class":58},"'f1'",[41,4315,4316],{"class":47},");       ",[41,4318,4319],{"class":198},"\u002F\u002F undefined\n",[11,4321,4322,4323,4326,4327,4329],{},"The server did its job. It found the document and sent it, stamped with the collection it actually lives in, ",[38,4324,4325],{},"files",". The ",[38,4328,4224],{}," resolved, so your app believes the subscription is healthy. And yet the store is empty. Nothing complained. That silence is the whole bug.",[23,4331,4333],{"id":4332},"where-did-the-document-go","Where did the document go?",[11,4335,4336],{},"To route an incoming document into the right store, the client has to answer one question: which collection does this belong to, and is anyone still subscribed to it? The original code answered it with a guess. It took the publication name and split it on the first dot:",[31,4338,4340],{"className":133,"code":4339,"language":135,"meta":36,"style":36},"name.split('.')[0]   \u002F\u002F 'recentFiles' -> 'recentFiles'\n",[38,4341,4342],{"__ignoreMap":36},[41,4343,4344,4347,4350,4352,4355,4358,4360,4363],{"class":43,"line":44},[41,4345,4346],{"class":47},"name.",[41,4348,4349],{"class":1265},"split",[41,4351,1314],{"class":47},[41,4353,4354],{"class":58},"'.'",[41,4356,4357],{"class":47},")[",[41,4359,3796],{"class":105},[41,4361,4362],{"class":47},"]   ",[41,4364,4365],{"class":198},"\u002F\u002F 'recentFiles' -> 'recentFiles'\n",[11,4367,4368,4369,4372,4373,4375,4376,4378,4379,4381],{},"The publication is called ",[38,4370,4371],{},"recentFiles",". The collection is ",[38,4374,4325],{},". The guess says ",[38,4377,4371],{},". They do not match, so the gate decides nothing is live for ",[38,4380,4325],{},", and the document is dropped before it ever reaches the store.",[11,4383,4384,4385,4388,4389,867,4391,867,4394,4397],{},"The bug only hides when the name happens to start with the collection. Call your publication ",[38,4386,4387],{},"files.recent"," and it works, by luck. Call it anything a person would actually choose, ",[38,4390,4371],{},[38,4392,4393],{},"archivedThisWeek",[38,4395,4396],{},"myInbox",", and it fails in silence. The name is for people. The collection is the server's business. The moment those two ideas drift apart, the data goes missing and the app smiles back at you.",[23,4399,4401],{"id":4400},"a-question-worth-sitting-with","A question worth sitting with",[11,4403,4404],{},"So we asked the question underneath the bug: where should the collection actually come from?",[11,4406,4407],{},"Not the name. We just watched why. The honest answer is the server, because the server is the only thing that knows where a document lives. So we went looking for the place where the server tells the client, and found that it never did.",[11,4409,4410],{},"Look at the messages on the wire. A data message carries the collection but no subscription id:",[31,4412,4414],{"className":133,"code":4413,"language":135,"meta":36,"style":36},"{ type: 'added', collection: 'files', id: 'f1', fields: { ... } }\n",[38,4415,4416],{"__ignoreMap":36},[41,4417,4418,4421,4424,4426,4429,4431,4434,4436,4438,4440,4443,4445,4447,4449,4452,4455,4458],{"class":43,"line":44},[41,4419,4420],{"class":47},"{ ",[41,4422,4423],{"class":1265},"type",[41,4425,55],{"class":47},[41,4427,4428],{"class":58},"'added'",[41,4430,867],{"class":47},[41,4432,4433],{"class":1265},"collection",[41,4435,55],{"class":47},[41,4437,4263],{"class":58},[41,4439,867],{"class":47},[41,4441,4442],{"class":1265},"id",[41,4444,55],{"class":47},[41,4446,4313],{"class":58},[41,4448,867],{"class":47},[41,4450,4451],{"class":1265},"fields",[41,4453,4454],{"class":47},": { ",[41,4456,4457],{"class":81},"...",[41,4459,4460],{"class":47}," } }\n",[11,4462,3881,4463,4465],{},[38,4464,4224],{}," carries the subscription id but no collection:",[31,4467,4469],{"className":133,"code":4468,"language":135,"meta":36,"style":36},"{ type: 'ready', id: 'sub-123' }\n",[38,4470,4471],{"__ignoreMap":36},[41,4472,4473,4475,4477,4479,4482,4484,4486,4488,4491],{"class":43,"line":44},[41,4474,4420],{"class":47},[41,4476,4423],{"class":1265},[41,4478,55],{"class":47},[41,4480,4481],{"class":58},"'ready'",[41,4483,867],{"class":47},[41,4485,4442],{"class":1265},[41,4487,55],{"class":47},[41,4489,4490],{"class":58},"'sub-123'",[41,4492,4493],{"class":47}," }\n",[11,4495,4496],{},"Neither message, on its own, ties a subscription to its collection. The client had no way to know the truth, so it guessed. The guess was not the disease. It was a symptom of a protocol that quietly forgot to say the one thing that mattered.",[23,4498,4500],{"id":4499},"the-fix-is-one-field","The fix is one field",[11,4502,4503,4504,1340],{},"We added the collection to ",[38,4505,4224],{},[31,4507,4509],{"className":133,"code":4508,"language":135,"meta":36,"style":36},"{ type: 'ready', id: 'sub-123', collection: 'files' }\n",[38,4510,4511],{"__ignoreMap":36},[41,4512,4513,4515,4517,4519,4521,4523,4525,4527,4529,4531,4533,4535,4537],{"class":43,"line":44},[41,4514,4420],{"class":47},[41,4516,4423],{"class":1265},[41,4518,55],{"class":47},[41,4520,4481],{"class":58},[41,4522,867],{"class":47},[41,4524,4442],{"class":1265},[41,4526,55],{"class":47},[41,4528,4490],{"class":58},[41,4530,867],{"class":47},[41,4532,4433],{"class":1265},[41,4534,55],{"class":47},[41,4536,4263],{"class":58},[41,4538,4493],{"class":47},[11,4540,4541],{},"The server had this in hand the whole time. Now it says so out loud. The client stops guessing, binds the subscription to the collection the server names, and routes the documents it was holding into the right store. The name goes back to being what it always should have been, a label for humans.",[11,4543,4544,4545,4547,4548,4550,4551,4553],{},"There is a small timing wrinkle worth naming. The server sends the initial documents before it sends ",[38,4546,4224],{},", so for a brief moment the client is holding documents it cannot place yet. It parks them, and the moment ",[38,4549,4224],{}," names the collection, it drains them into the store. The buffer solves the timing. The collection on ",[38,4552,4224],{}," solves the truth. Two different problems, two different answers.",[11,4555,4556,4557,4559,4560,4563],{},"With the collection known for certain, the job we set out to do, gating late messages after ",[38,4558,4234],{},", falls out for free. The gate asks \"is this collection still live?\" and that question finally has an honest answer, so a stray ",[38,4561,4562],{},"changed"," arriving after you have stopped lands nowhere.",[23,4565,4567],{"id":4566},"the-wrong-turn-we-left-in","The wrong turn we left in",[11,4569,4570,4571,4573],{},"Here is the wrong turn, kept in on purpose. The first version we shipped made the new field optional, and kept a fallback: if ",[38,4572,4224],{}," did not carry a collection, infer it from whichever document we happened to be holding in the buffer. Every test passed. It looked robust.",[11,4575,4576,4577,4579],{},"It was not robust. It was a story we were telling ourselves. The only thing that ever exercised that fallback was the tests, written in just the right order to keep it happy. No real server ever sent a ",[38,4578,4224],{}," without a collection, because we had just changed the one server we have to always send it. A fallback that only your own tests keep alive is not a safety net. It is dead weight in a high visibility jacket.",[11,4581,4582,4583,4585],{},"So we made the field required, enforced it at the point where messages are checked coming off the wire, and deleted the fallback. If a ",[38,4584,4224],{}," turns up without a collection now, it is turned away at the door rather than papered over. The type makes the gap impossible to reopen, by anyone, later, on a tired afternoon.",[23,4587,4589],{"id":4588},"the-move-you-can-reproduce","The move you can reproduce",[11,4591,4592],{},"If you would rather feel this one than read about it, the move is short. Pin the symptom with a failing test before you touch a line of the fix:",[31,4594,4596],{"className":133,"code":4595,"language":135,"meta":36,"style":36},"it('routes data when the publication name is not the collection name', async () => {\n  const store = manager.store('files');\n  const handle = manager.subscribe('recentFiles');   \u002F\u002F named for meaning, not storage\n\n  server.send({ type: 'added', collection: 'files', id: '1', fields: { name: 'a.bam' } });\n  server.send({ type: 'ready', id: subId, collection: 'files' });\n  await handle.ready;\n\n  expect(store.getById('1')).toEqual({ _id: '1', name: 'a.bam' });\n});\n",[38,4597,4598,4619,4638,4660,4664,4697,4714,4721,4725,4757],{"__ignoreMap":36},[41,4599,4600,4603,4605,4608,4610,4612,4615,4617],{"class":43,"line":44},[41,4601,4602],{"class":1265},"it",[41,4604,1314],{"class":47},[41,4606,4607],{"class":58},"'routes data when the publication name is not the collection name'",[41,4609,867],{"class":47},[41,4611,1663],{"class":81},[41,4613,4614],{"class":47}," () ",[41,4616,1353],{"class":81},[41,4618,1275],{"class":47},[41,4620,4621,4624,4626,4628,4630,4632,4634,4636],{"class":43,"line":62},[41,4622,4623],{"class":81},"  const",[41,4625,4250],{"class":105},[41,4627,148],{"class":81},[41,4629,4255],{"class":47},[41,4631,4258],{"class":1265},[41,4633,1314],{"class":47},[41,4635,4263],{"class":58},[41,4637,1435],{"class":47},[41,4639,4640,4642,4644,4646,4648,4650,4652,4654,4657],{"class":43,"line":73},[41,4641,4623],{"class":81},[41,4643,4272],{"class":105},[41,4645,148],{"class":81},[41,4647,4255],{"class":47},[41,4649,4279],{"class":1265},[41,4651,1314],{"class":47},[41,4653,4284],{"class":58},[41,4655,4656],{"class":47},");   ",[41,4658,4659],{"class":198},"\u002F\u002F named for meaning, not storage\n",[41,4661,4662],{"class":43,"line":85},[41,4663,179],{"emptyLinePlaceholder":178},[41,4665,4666,4669,4672,4675,4677,4680,4682,4685,4688,4691,4694],{"class":43,"line":91},[41,4667,4668],{"class":47},"  server.",[41,4670,4671],{"class":1265},"send",[41,4673,4674],{"class":47},"({ type: ",[41,4676,4428],{"class":58},[41,4678,4679],{"class":47},", collection: ",[41,4681,4263],{"class":58},[41,4683,4684],{"class":47},", id: ",[41,4686,4687],{"class":58},"'1'",[41,4689,4690],{"class":47},", fields: { name: ",[41,4692,4693],{"class":58},"'a.bam'",[41,4695,4696],{"class":47}," } });\n",[41,4698,4699,4701,4703,4705,4707,4710,4712],{"class":43,"line":97},[41,4700,4668],{"class":47},[41,4702,4671],{"class":1265},[41,4704,4674],{"class":47},[41,4706,4481],{"class":58},[41,4708,4709],{"class":47},", id: subId, collection: ",[41,4711,4263],{"class":58},[41,4713,4097],{"class":47},[41,4715,4716,4718],{"class":43,"line":1358},[41,4717,4085],{"class":81},[41,4719,4720],{"class":47}," handle.ready;\n",[41,4722,4723],{"class":43,"line":1390},[41,4724,179],{"emptyLinePlaceholder":178},[41,4726,4727,4730,4733,4735,4737,4739,4742,4745,4748,4750,4753,4755],{"class":43,"line":1407},[41,4728,4729],{"class":1265},"  expect",[41,4731,4732],{"class":47},"(store.",[41,4734,4308],{"class":1265},[41,4736,1314],{"class":47},[41,4738,4687],{"class":58},[41,4740,4741],{"class":47},")).",[41,4743,4744],{"class":1265},"toEqual",[41,4746,4747],{"class":47},"({ _id: ",[41,4749,4687],{"class":58},[41,4751,4752],{"class":47},", name: ",[41,4754,4693],{"class":58},[41,4756,4097],{"class":47},[41,4758,4759],{"class":43,"line":1438},[41,4760,2019],{"class":47},[11,4762,4763],{},"Run it against a client that guesses from the name and watch it go red. Then let the server name the collection, and watch it go green. The red is the lesson. The green is just the receipt.",[23,4765,4767],{"id":4766},"why-we-work-this-way","Why we work this way",[11,4769,4770],{},"None of this was hard once we stopped guessing. The work was in refusing to read a green test suite as proof, and asking where the truth actually lived. That is most of the work. We let the data tell us what is true, we write the test that fails for the right reason first, and we stay suspicious of any code that only our own tests seem to need.",[11,4772,4773],{},"A subscription should never have to guess where its own data lives. Now it does not.",[348,4775,4776],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":36,"searchDepth":62,"depth":62,"links":4778},[4779,4780,4781,4782,4783,4784],{"id":4332,"depth":62,"text":4333},{"id":4400,"depth":62,"text":4401},{"id":4499,"depth":62,"text":4500},{"id":4566,"depth":62,"text":4567},{"id":4588,"depth":62,"text":4589},{"id":4766,"depth":62,"text":4767},"2026-07-05T10:16:16.000Z","A bug that threw no error and dropped every document, because the client guessed a collection from a publication name. The fix was one honest field on the ready message.",{},"\u002Fblog\u002Fthe-bug-that-resolved-successfully","EkoLite",{"title":4216,"description":4786},"blog\u002Fthe-bug-that-resolved-successfully",[4793,4794,3001,4795],"protocol","pubsub","tdd","JIecHLkEGyZs0KRowvGxpufBSq-B3Y_jZ4sHwJ_2iUY",{"id":4798,"title":4799,"author":6,"body":4800,"category":3906,"date":5640,"description":5641,"draft":362,"extension":363,"image":364,"meta":5642,"navigation":178,"path":5643,"project":4789,"readingMinutes":97,"seo":5644,"stem":5645,"tags":5646,"__hash__":5648},"blog\u002Fblog\u002Fthe-clock-that-learned-to-walk.md","The Clock That Learned to Walk",{"type":8,"value":4801,"toc":5633},[4802,4813,4816,4820,4827,4830,4833,5051,5062,5066,5069,5243,5249,5266,5269,5273,5285,5582,5594,5606,5609,5613,5620,5623,5627,5630],[11,4803,4804,4805,4808,4809,4812],{},"EkoLite, our small real time backend, keeps a fake clock. Not for production, where time is the operating system's job, but for tests, where time has to be something a test can hold in its hand. When the shutdown policy arms a five second deadline, no test wants to wait five real seconds to watch it fire. So the nulled ",[38,4806,4807],{},"ProcessWrapper"," carries a stub clock, and a test moves time by calling ",[38,4810,4811],{},"advanceTime(5000)"," instead of sleeping.",[11,4814,4815],{},"A stub like that is a small promise. It promises that time, inside the test, behaves the way time behaves outside it. Break the promise quietly and you get the worst kind of test: one that passes for reasons that have nothing to do with the code it claims to cover. This is the story of a stub clock that made two promises, broke one of them in a way no test could see, and what it took to make it walk instead of jump.",[23,4817,4819],{"id":4818},"what-a-fake-clock-owes-you","What a fake clock owes you",[11,4821,4822,4823,4826],{},"The real ",[38,4824,4825],{},"setTimeout"," makes two guarantees worth naming. Timers fire in deadline order: a timer due at 10ms fires before one due at 20ms, whatever order you scheduled them in. And time passes through every moment on the way: a timer scheduled from inside another timer's callback takes its deadline from the moment that callback ran, not from some later point the clock has already reached.",[11,4828,4829],{},"The stub's first version honoured neither cleanly. It fired timers in the order they were scheduled, so a later call could run before an earlier deadline. That is exactly the sort of bug a refactor is supposed to surface, and this one did. The fix sorted the due timers by deadline before firing them, and a new test pinned the order down. Good work as far as it goes.",[11,4831,4832],{},"But look at the shape of that fix, because the second promise is hiding inside it.",[31,4834,4836],{"className":133,"code":4835,"language":135,"meta":36,"style":36},"advanceTime(ms: number): void {\n  this.now += ms;\n\n  const dueTimers = this.timers\n    .filter((timer) => timer.live && timer.dueAt \u003C= this.now)\n    .sort((left, right) => left.dueAt - right.dueAt);\n\n  for (const timer of dueTimers) {\n    timer.live = false;\n    timer.callback();\n  }\n\n  this.timers.splice(0, this.timers.length, ...this.timers.filter((timer) => timer.live));\n}\n",[38,4837,4838,4851,4865,4869,4884,4918,4948,4952,4970,4983,4994,4998,5002,5047],{"__ignoreMap":36},[41,4839,4840,4843,4846,4849],{"class":43,"line":44},[41,4841,4842],{"class":1265},"advanceTime",[41,4844,4845],{"class":47},"(ms: number): ",[41,4847,4848],{"class":81},"void",[41,4850,1275],{"class":47},[41,4852,4853,4856,4859,4862],{"class":43,"line":62},[41,4854,4855],{"class":105},"  this",[41,4857,4858],{"class":47},".now ",[41,4860,4861],{"class":81},"+=",[41,4863,4864],{"class":47}," ms;\n",[41,4866,4867],{"class":43,"line":73},[41,4868,179],{"emptyLinePlaceholder":178},[41,4870,4871,4873,4876,4878,4881],{"class":43,"line":85},[41,4872,4623],{"class":81},[41,4874,4875],{"class":105}," dueTimers",[41,4877,148],{"class":81},[41,4879,4880],{"class":105}," this",[41,4882,4883],{"class":47},".timers\n",[41,4885,4886,4889,4892,4895,4898,4900,4902,4905,4907,4910,4913,4915],{"class":43,"line":91},[41,4887,4888],{"class":47},"    .",[41,4890,4891],{"class":1265},"filter",[41,4893,4894],{"class":47},"((",[41,4896,4897],{"class":1280},"timer",[41,4899,1350],{"class":47},[41,4901,1353],{"class":81},[41,4903,4904],{"class":47}," timer.live ",[41,4906,190],{"class":81},[41,4908,4909],{"class":47}," timer.dueAt ",[41,4911,4912],{"class":81},"\u003C=",[41,4914,4880],{"class":105},[41,4916,4917],{"class":47},".now)\n",[41,4919,4920,4922,4925,4927,4930,4932,4935,4937,4939,4942,4945],{"class":43,"line":97},[41,4921,4888],{"class":47},[41,4923,4924],{"class":1265},"sort",[41,4926,4894],{"class":47},[41,4928,4929],{"class":1280},"left",[41,4931,867],{"class":47},[41,4933,4934],{"class":1280},"right",[41,4936,1350],{"class":47},[41,4938,1353],{"class":81},[41,4940,4941],{"class":47}," left.dueAt ",[41,4943,4944],{"class":81},"-",[41,4946,4947],{"class":47}," right.dueAt);\n",[41,4949,4950],{"class":43,"line":1358},[41,4951,179],{"emptyLinePlaceholder":178},[41,4953,4954,4957,4959,4961,4964,4967],{"class":43,"line":1390},[41,4955,4956],{"class":81},"  for",[41,4958,1334],{"class":47},[41,4960,142],{"class":81},[41,4962,4963],{"class":105}," timer",[41,4965,4966],{"class":81}," of",[41,4968,4969],{"class":47}," dueTimers) {\n",[41,4971,4972,4975,4978,4981],{"class":43,"line":1407},[41,4973,4974],{"class":47},"    timer.live ",[41,4976,4977],{"class":81},"=",[41,4979,4980],{"class":105}," false",[41,4982,157],{"class":47},[41,4984,4985,4988,4991],{"class":43,"line":1438},[41,4986,4987],{"class":47},"    timer.",[41,4989,4990],{"class":1265},"callback",[41,4992,4993],{"class":47},"();\n",[41,4995,4996],{"class":43,"line":1463},[41,4997,1576],{"class":47},[41,4999,5000],{"class":43,"line":1468},[41,5001,179],{"emptyLinePlaceholder":178},[41,5003,5004,5006,5009,5012,5014,5016,5018,5021,5023,5026,5028,5030,5032,5034,5036,5038,5040,5042,5044],{"class":43,"line":1490},[41,5005,4855],{"class":105},[41,5007,5008],{"class":47},".timers.",[41,5010,5011],{"class":1265},"splice",[41,5013,1314],{"class":47},[41,5015,3796],{"class":105},[41,5017,867],{"class":47},[41,5019,5020],{"class":105},"this",[41,5022,5008],{"class":47},[41,5024,5025],{"class":105},"length",[41,5027,867],{"class":47},[41,5029,4457],{"class":81},[41,5031,5020],{"class":105},[41,5033,5008],{"class":47},[41,5035,4891],{"class":1265},[41,5037,4894],{"class":47},[41,5039,4897],{"class":1280},[41,5041,1350],{"class":47},[41,5043,1353],{"class":81},[41,5045,5046],{"class":47}," timer.live));\n",[41,5048,5049],{"class":43,"line":1499},[41,5050,204],{"class":47},[11,5052,5053,5054,5057,5058,5061],{},"The first line moves the whole clock to the end of the window. ",[38,5055,5056],{},"advanceTime(100)"," sets ",[38,5059,5060],{},"now"," to 100 before a single callback has run. Then it works out which timers are due and fires them, in deadline order now, which is the win. But time did not pass. Time teleported.",[23,5063,5065],{"id":5064},"the-timer-that-arrived-too-late-to-its-own-party","The timer that arrived too late to its own party",[11,5067,5068],{},"Here is the promise that broke. Ask the stub to fire a timer that schedules another timer.",[31,5070,5072],{"className":133,"code":5071,"language":135,"meta":36,"style":36},"it('fires a timer scheduled by another timer, when both come due in the same advance', () => {\n  const proc = ProcessWrapper.createNull();\n  const order: number[] = [];\n\n  proc.startTimer(10, () => {\n    order.push(10);\n    proc.startTimer(5, () => order.push(15));\n  });\n\n  proc.advanceTime(100);\n\n  expect(order).toEqual([10, 15]);\n});\n",[38,5073,5074,5090,5107,5127,5131,5150,5164,5193,5197,5201,5214,5218,5239],{"__ignoreMap":36},[41,5075,5076,5078,5080,5083,5086,5088],{"class":43,"line":44},[41,5077,4602],{"class":1265},[41,5079,1314],{"class":47},[41,5081,5082],{"class":58},"'fires a timer scheduled by another timer, when both come due in the same advance'",[41,5084,5085],{"class":47},", () ",[41,5087,1353],{"class":81},[41,5089,1275],{"class":47},[41,5091,5092,5094,5097,5099,5102,5105],{"class":43,"line":62},[41,5093,4623],{"class":81},[41,5095,5096],{"class":105}," proc",[41,5098,148],{"class":81},[41,5100,5101],{"class":47}," ProcessWrapper.",[41,5103,5104],{"class":1265},"createNull",[41,5106,4993],{"class":47},[41,5108,5109,5111,5114,5116,5119,5122,5124],{"class":43,"line":73},[41,5110,4623],{"class":81},[41,5112,5113],{"class":105}," order",[41,5115,1340],{"class":81},[41,5117,5118],{"class":105}," number",[41,5120,5121],{"class":47},"[] ",[41,5123,4977],{"class":81},[41,5125,5126],{"class":47}," [];\n",[41,5128,5129],{"class":43,"line":85},[41,5130,179],{"emptyLinePlaceholder":178},[41,5132,5133,5136,5139,5141,5144,5146,5148],{"class":43,"line":91},[41,5134,5135],{"class":47},"  proc.",[41,5137,5138],{"class":1265},"startTimer",[41,5140,1314],{"class":47},[41,5142,5143],{"class":105},"10",[41,5145,5085],{"class":47},[41,5147,1353],{"class":81},[41,5149,1275],{"class":47},[41,5151,5152,5155,5158,5160,5162],{"class":43,"line":97},[41,5153,5154],{"class":47},"    order.",[41,5156,5157],{"class":1265},"push",[41,5159,1314],{"class":47},[41,5161,5143],{"class":105},[41,5163,1435],{"class":47},[41,5165,5166,5169,5171,5173,5176,5178,5180,5183,5185,5187,5190],{"class":43,"line":1358},[41,5167,5168],{"class":47},"    proc.",[41,5170,5138],{"class":1265},[41,5172,1314],{"class":47},[41,5174,5175],{"class":105},"5",[41,5177,5085],{"class":47},[41,5179,1353],{"class":81},[41,5181,5182],{"class":47}," order.",[41,5184,5157],{"class":1265},[41,5186,1314],{"class":47},[41,5188,5189],{"class":105},"15",[41,5191,5192],{"class":47},"));\n",[41,5194,5195],{"class":43,"line":1390},[41,5196,1894],{"class":47},[41,5198,5199],{"class":43,"line":1407},[41,5200,179],{"emptyLinePlaceholder":178},[41,5202,5203,5205,5207,5209,5212],{"class":43,"line":1438},[41,5204,5135],{"class":47},[41,5206,4842],{"class":1265},[41,5208,1314],{"class":47},[41,5210,5211],{"class":105},"100",[41,5213,1435],{"class":47},[41,5215,5216],{"class":43,"line":1463},[41,5217,179],{"emptyLinePlaceholder":178},[41,5219,5220,5222,5225,5227,5230,5232,5234,5236],{"class":43,"line":1468},[41,5221,4729],{"class":1265},[41,5223,5224],{"class":47},"(order).",[41,5226,4744],{"class":1265},[41,5228,5229],{"class":47},"([",[41,5231,5143],{"class":105},[41,5233,867],{"class":47},[41,5235,5189],{"class":105},[41,5237,5238],{"class":47},"]);\n",[41,5240,5241],{"class":43,"line":1490},[41,5242,2019],{"class":47},[11,5244,5245,5246,756],{},"A ten millisecond timer whose callback schedules a five millisecond one. Under the real clock the outer fires at 10ms, and the inner, measured from there, fires at 15ms, comfortably inside a hundred millisecond window. The test expects ",[38,5247,5248],{},"[10, 15]",[11,5250,5251,5252,5255,5256,5258,5259,5262,5263,5265],{},"The stub returns ",[38,5253,5254],{},"[10]",". The inner timer never fires. And the reason is the teleport. By the time the outer callback runs, ",[38,5257,5060],{}," is already 100. ",[38,5260,5261],{},"startTimer(5)"," reads that ",[38,5264,5060],{}," and gives the new timer a deadline of 105. The window ended at 100. The timer was born five milliseconds after the world it lives in had stopped. It sits there, live and unfired, waiting for a future this advance already ran past.",[11,5267,5268],{},"Notice what kind of failure this is. Nothing throws. The suite is green. It stays green precisely because nothing in the codebase yet schedules a timer from inside a timer, so the broken promise costs nothing today and everything the day someone writes a retry that re arms a deadline. The test that would have caught it is the test nobody had a reason to write.",[23,5270,5272],{"id":5271},"teaching-the-clock-to-walk","Teaching the clock to walk",[11,5274,5275,5276,5278,5279,5281,5282,5284],{},"The fix is not another special case. It is to stop teleporting. A real clock passes through every instant between here and the target, and it fires each timer at the instant it comes due, with ",[38,5277,5060],{}," actually sitting at that instant while the callback runs. So the stub should walk the same road: find the earliest timer that is due, move ",[38,5280,5060],{}," to exactly its deadline, fire it, and look again. Timers scheduled by that callback are measured against a truthful ",[38,5283,5060],{},", and the next lap of the loop picks them up if they land inside the window.",[31,5286,5288],{"className":133,"code":5287,"language":135,"meta":36,"style":36},"advanceTime(ms: number): void {\n  const target = this.now + ms;\n\n  while (true) {\n    const nextTimer = this.timers.reduce\u003CStubbedTimer | null>((earliest, timer) => {\n      if (!timer.live || timer.dueAt > target) {\n        return earliest;\n      }\n      if (earliest === null || timer.dueAt \u003C earliest.dueAt) {\n        return timer;\n      }\n      return earliest;\n    }, null);\n\n    if (nextTimer === null) {\n      this.now = target;\n      break;\n    }\n\n    this.now = nextTimer.dueAt;\n\n    const index = this.timers.indexOf(nextTimer);\n    this.timers.splice(index, 1);\n\n    nextTimer.callback();\n  }\n}\n",[38,5289,5290,5300,5318,5322,5334,5377,5400,5408,5413,5434,5441,5445,5451,5461,5465,5478,5490,5497,5501,5505,5517,5521,5540,5557,5562,5572,5577],{"__ignoreMap":36},[41,5291,5292,5294,5296,5298],{"class":43,"line":44},[41,5293,4842],{"class":1265},[41,5295,4845],{"class":47},[41,5297,4848],{"class":81},[41,5299,1275],{"class":47},[41,5301,5302,5304,5307,5309,5311,5313,5316],{"class":43,"line":62},[41,5303,4623],{"class":81},[41,5305,5306],{"class":105}," target",[41,5308,148],{"class":81},[41,5310,4880],{"class":105},[41,5312,4858],{"class":47},[41,5314,5315],{"class":81},"+",[41,5317,4864],{"class":47},[41,5319,5320],{"class":43,"line":73},[41,5321,179],{"emptyLinePlaceholder":178},[41,5323,5324,5327,5329,5331],{"class":43,"line":85},[41,5325,5326],{"class":81},"  while",[41,5328,1334],{"class":47},[41,5330,1323],{"class":105},[41,5332,5333],{"class":47},") {\n",[41,5335,5336,5338,5341,5343,5345,5347,5350,5353,5356,5358,5361,5364,5367,5369,5371,5373,5375],{"class":43,"line":91},[41,5337,1361],{"class":81},[41,5339,5340],{"class":105}," nextTimer",[41,5342,148],{"class":81},[41,5344,4880],{"class":105},[41,5346,5008],{"class":47},[41,5348,5349],{"class":1265},"reduce",[41,5351,5352],{"class":47},"\u003C",[41,5354,5355],{"class":1265},"StubbedTimer",[41,5357,1290],{"class":81},[41,5359,5360],{"class":105}," null",[41,5362,5363],{"class":47},">((",[41,5365,5366],{"class":1280},"earliest",[41,5368,867],{"class":47},[41,5370,4897],{"class":1280},[41,5372,1350],{"class":47},[41,5374,1353],{"class":81},[41,5376,1275],{"class":47},[41,5378,5379,5382,5384,5386,5389,5392,5394,5397],{"class":43,"line":97},[41,5380,5381],{"class":81},"      if",[41,5383,1334],{"class":47},[41,5385,1415],{"class":81},[41,5387,5388],{"class":47},"timer.live ",[41,5390,5391],{"class":81},"||",[41,5393,4909],{"class":47},[41,5395,5396],{"class":81},">",[41,5398,5399],{"class":47}," target) {\n",[41,5401,5402,5405],{"class":43,"line":1358},[41,5403,5404],{"class":81},"        return",[41,5406,5407],{"class":47}," earliest;\n",[41,5409,5410],{"class":43,"line":1390},[41,5411,5412],{"class":47},"      }\n",[41,5414,5415,5417,5420,5422,5424,5427,5429,5431],{"class":43,"line":1407},[41,5416,5381],{"class":81},[41,5418,5419],{"class":47}," (earliest ",[41,5421,1484],{"class":81},[41,5423,5360],{"class":105},[41,5425,5426],{"class":81}," ||",[41,5428,4909],{"class":47},[41,5430,5352],{"class":81},[41,5432,5433],{"class":47}," earliest.dueAt) {\n",[41,5435,5436,5438],{"class":43,"line":1438},[41,5437,5404],{"class":81},[41,5439,5440],{"class":47}," timer;\n",[41,5442,5443],{"class":43,"line":1463},[41,5444,5412],{"class":47},[41,5446,5447,5449],{"class":43,"line":1468},[41,5448,1541],{"class":81},[41,5450,5407],{"class":47},[41,5452,5453,5456,5459],{"class":43,"line":1490},[41,5454,5455],{"class":47},"    }, ",[41,5457,5458],{"class":105},"null",[41,5460,1435],{"class":47},[41,5462,5463],{"class":43,"line":1499},[41,5464,179],{"emptyLinePlaceholder":178},[41,5466,5467,5469,5472,5474,5476],{"class":43,"line":1518},[41,5468,1410],{"class":81},[41,5470,5471],{"class":47}," (nextTimer ",[41,5473,1484],{"class":81},[41,5475,5360],{"class":105},[41,5477,5333],{"class":47},[41,5479,5480,5483,5485,5487],{"class":43,"line":1523},[41,5481,5482],{"class":105},"      this",[41,5484,4858],{"class":47},[41,5486,4977],{"class":81},[41,5488,5489],{"class":47}," target;\n",[41,5491,5492,5495],{"class":43,"line":1538},[41,5493,5494],{"class":81},"      break",[41,5496,157],{"class":47},[41,5498,5499],{"class":43,"line":1556},[41,5500,1559],{"class":47},[41,5502,5503],{"class":43,"line":1562},[41,5504,179],{"emptyLinePlaceholder":178},[41,5506,5507,5510,5512,5514],{"class":43,"line":1567},[41,5508,5509],{"class":105},"    this",[41,5511,4858],{"class":47},[41,5513,4977],{"class":81},[41,5515,5516],{"class":47}," nextTimer.dueAt;\n",[41,5518,5519],{"class":43,"line":1573},[41,5520,179],{"emptyLinePlaceholder":178},[41,5522,5523,5525,5528,5530,5532,5534,5537],{"class":43,"line":1579},[41,5524,1361],{"class":81},[41,5526,5527],{"class":105}," index",[41,5529,148],{"class":81},[41,5531,4880],{"class":105},[41,5533,5008],{"class":47},[41,5535,5536],{"class":1265},"indexOf",[41,5538,5539],{"class":47},"(nextTimer);\n",[41,5541,5543,5545,5547,5549,5552,5555],{"class":43,"line":5542},23,[41,5544,5509],{"class":105},[41,5546,5008],{"class":47},[41,5548,5011],{"class":1265},[41,5550,5551],{"class":47},"(index, ",[41,5553,5554],{"class":105},"1",[41,5556,1435],{"class":47},[41,5558,5560],{"class":43,"line":5559},24,[41,5561,179],{"emptyLinePlaceholder":178},[41,5563,5565,5568,5570],{"class":43,"line":5564},25,[41,5566,5567],{"class":47},"    nextTimer.",[41,5569,4990],{"class":1265},[41,5571,4993],{"class":47},[41,5573,5575],{"class":43,"line":5574},26,[41,5576,1576],{"class":47},[41,5578,5580],{"class":43,"line":5579},27,[41,5581,204],{"class":47},[11,5583,5584,5585,5587,5588,5590,5591,5593],{},"Save the target first. Then loop: reach for the earliest live timer due at or before the target, step ",[38,5586,5060],{}," to its deadline, take it out of the queue, and let it run. When the callback schedules a new timer, that timer reads the honest ",[38,5589,5060],{}," and gets a deadline in the present, not stranded past the end. When nothing is left due, move ",[38,5592,5060],{}," to the target and stop.",[11,5595,5596,5597,5599,5600,5602,5603,5605],{},"The nested timer fires now. The outer runs with ",[38,5598,5060],{}," at 10, the inner is scheduled for 15, the loop comes back round, finds it inside the window, and fires it with ",[38,5601,5060],{}," at 15. ",[38,5604,5248],{},", the same answer the real clock gives.",[11,5607,5608],{},"And the walk quietly dissolves two things the teleport had needed. The deadline sort is gone, because taking the earliest timer on every lap is the ordering. The separate cleanup line that pruned fired timers is gone too, because a timer leaves the queue the moment it fires. One honest mechanism did the work of three careful ones. That is usually the sign you have found the right depth: the special cases do not get handled, they stop existing.",[23,5610,5612],{"id":5611},"why-this-is-the-whole-game-for-us","Why this is the whole game for us",[11,5614,5615,5616,756],{},"We run on real work read closely. An engineer takes a refactoring story on EkoLite, the pull request comes back, and the review reads the reasoning under the diff as much as the diff itself. The first version of this clock was not careless. It found a real ordering bug and pinned it with a test, which is more than most refactors manage. The gap it left was a promise nobody had written a test for, and the review's job was to write that test, watch it go red, and point at the mechanism rather than dictate the lines. That review, and the missing question it kept turning up across the same pull request, is ",[14,5617,5619],{"href":5618},"\u002Fblog\u002Fthe-line-nothing-could-see","The line nothing could see",[11,5621,5622],{},"The rest was the engineer's. The drain loop above is their answer to a red test, not a patch copied from a comment. That is the difference we care about. A verdict teaches one fix. A failing test and a nudge toward the mechanism teaches the question you can carry to the next stub, the next clock, the next promise you were tempted to keep by teleporting past it.",[23,5624,5626],{"id":5625},"the-move-to-take-home","The move to take home",[11,5628,5629],{},"When you fake a resource, list the promises the real one makes before you make the real one's job easier. A clock promises order and passage. It is easy to buy order with a sort and lose passage to a teleport, because the teleport is simpler to write and the loss is invisible until code leans on it. Model the thing the way it actually behaves, one step at a time, and the invisible cases become ordinary ones. The clock that walks fires every timer the real one would. The clock that jumps fires only the ones you happened to test.",[348,5631,5632],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}",{"title":36,"searchDepth":62,"depth":62,"links":5634},[5635,5636,5637,5638,5639],{"id":4818,"depth":62,"text":4819},{"id":5064,"depth":62,"text":5065},{"id":5271,"depth":62,"text":5272},{"id":5611,"depth":62,"text":5612},{"id":5625,"depth":62,"text":5626},"2026-07-07T22:44:42.000Z","A stub clock that teleported to the end of the window instead of passing through time, so a timer scheduled inside another never fired. Modelling the real clock's promises fixes it and dissolves two special cases.",{},"\u002Fblog\u002Fthe-clock-that-learned-to-walk",{"title":4799,"description":5641},"blog\u002Fthe-clock-that-learned-to-walk",[3914,3001,5647],"refactoring","etZiUhDPtBKtCcJB_flsCpqO_YAUZ1ZVBu2RgQ9woFU",{"id":5650,"title":5651,"author":6,"body":5652,"category":525,"date":5872,"description":5873,"draft":362,"extension":363,"image":364,"meta":5874,"navigation":178,"path":2270,"project":367,"readingMinutes":91,"seo":5875,"stem":5876,"tags":5877,"__hash__":5878},"blog\u002Fblog\u002Fthe-conflict-nobody-wrote.md","The Conflict Nobody Wrote",{"type":8,"value":5653,"toc":5865},[5654,5657,5668,5672,5677,5703,5706,5710,5713,5716,5719,5725,5732,5739,5743,5750,5757,5760,5803,5806,5810,5813,5819,5829,5835,5838,5844,5851,5853,5856,5859,5862],[11,5655,5656],{},"A pull request on the Dojo came back marked conflicting. It was the second of two slices of the same feature, a quota that stops a free cohort adding a sixth member, and while it sat open its sibling slice had merged to the trunk. So far this is the most ordinary story in software. What made it worth stopping over was where the conflict landed.",[11,5658,5659,5660,5663,5664,5667],{},"Both branches had edited ",[38,5661,5662],{},"participants.ts",". Both had added tests to the same test file. Git merged all of it without a murmur. The single file it could not reconcile was ",[38,5665,5666],{},"APP-BEHAVIOUR.md",", a document describing what the application does, which neither author had opened, and into which neither had typed a single character.",[23,5669,5671],{"id":5670},"what-the-document-is","What the document is",[11,5673,5674,5676],{},[38,5675,5666],{}," is generated. A script walks the integration tests, reads their names, and writes them out as a list of the things the application is known to do. Each entry carries the line in the test file where the guarantee lives, so a reader can go and see it:",[31,5678,5682],{"className":5679,"code":5680,"language":5681,"meta":36,"style":36},"language-markdown shiki shiki-themes github-light github-dark","**write authorisation**\n\n- Creating a participant requires an instructor session. (:423)\n- Updating a participant is self-or-instructor only. (:448)\n","markdown",[38,5683,5684,5689,5693,5698],{"__ignoreMap":36},[41,5685,5686],{"class":43,"line":44},[41,5687,5688],{},"**write authorisation**\n",[41,5690,5691],{"class":43,"line":62},[41,5692,179],{"emptyLinePlaceholder":178},[41,5694,5695],{"class":43,"line":73},[41,5696,5697],{},"- Creating a participant requires an instructor session. (:423)\n",[41,5699,5700],{"class":43,"line":85},[41,5701,5702],{},"- Updating a participant is self-or-instructor only. (:448)\n",[11,5704,5705],{},"That parenthesised number is the whole problem, and it will take a moment to see why, because at first glance it looks like the most innocent thing on the page.",[23,5707,5709],{"id":5708},"the-arithmetic","The arithmetic",[11,5711,5712],{},"The first slice, already merged to the trunk, had added a route for administrators to change a cohort's plan. Its tests went in near the bottom of the test file, fifty eight lines of them, sitting just above the authorisation block. So when its author regenerated the document, every entry below the insertion moved down by fifty eight. Line 423 became line 481.",[11,5714,5715],{},"The second slice, still on its branch, added the quota tests. Those went in much higher up, beside the other creation tests, sixty four lines of them. Its author regenerated too. Line 423 became line 487.",[11,5717,5718],{},"Now ask git to merge the two. It sees one line in the common ancestor and two different replacements, and it does the only honest thing available to it:",[31,5720,5723],{"className":5721,"code":5722,"language":122},[120],"\u003C\u003C\u003C\u003C\u003C\u003C\u003C HEAD\n- Creating a participant requires an instructor session. (:481)\n=======\n- Creating a participant requires an instructor session. (:487)\n>>>>>>> Enforce the members quota on member creation\n",[38,5724,5722],{"__ignoreMap":36},[11,5726,5727,5728,5731],{},"Sit with that for a second, because the interesting fact is not that git could not choose. It is that ",[413,5729,5730],{},"both choices are wrong",". In the merged world both blocks of tests exist, both insertions are above the authorisation block, and the guarantee lives at line 545. Four hundred and eighty one is what the line would be if the quota work had never happened. Four hundred and eighty seven is what it would be if the plan route had never happened. The number the reader needs is on neither side of the conflict, and no amount of care in resolving it could have produced 545, because 545 is not text that either branch contains. It is a fact about a file that only exists after the merge.",[11,5733,5734,5735,5738],{},"This is the moment the whole thing turns over. A conflict usually means two people disagreed and a human must decide. Here nobody disagreed. Nobody even had an opinion. Git did its job perfectly and the result was meaningless, because the thing it was asked to merge was not the truth. It was a ",[340,5736,5737],{},"photograph"," of the truth, taken from two positions, and you cannot combine two photographs by choosing one.",[23,5740,5742],{"id":5741},"a-generated-file-is-a-cache","A generated file is a cache",[11,5744,5745,5746,5749],{},"We have written before, in ",[14,5747,5748],{"href":969},"The fix was merged, but the numbers were still wrong",", that derived data is a cache and caches go stale. That post was about a database column. This is the same principle wearing different clothes, and the version control system is where it bites hardest, because git will cheerfully let you hand resolve a cache.",[11,5751,5752,5753,5756],{},"The rule underneath is short. ",[413,5754,5755],{},"You do not merge derived state. You invalidate it and derive it again."," Whether a merge conflict appears is not the signal. The absence of a conflict would have been worse: had the two slices inserted their tests far enough apart, git would have merged the document cleanly, produced a file where half the line numbers pointed at the wrong tests, and nobody would have seen a marker to warn them. The conflict was a courtesy.",[11,5758,5759],{},"So the resolution was never going to be a decision between 481 and 487. It was to put the branch on top of the current trunk, and run the script:",[31,5761,5763],{"className":2275,"code":5762,"language":2277,"meta":36,"style":36},"git fetch origin\ngit rebase origin\u002Fmain       # take either side of the document\ncd server && npm run docs:behaviour\n",[38,5764,5765,5773,5784],{"__ignoreMap":36},[41,5766,5767,5769,5771],{"class":43,"line":44},[41,5768,533],{"class":1265},[41,5770,2286],{"class":58},[41,5772,2289],{"class":58},[41,5774,5775,5777,5779,5781],{"class":43,"line":62},[41,5776,533],{"class":1265},[41,5778,2296],{"class":58},[41,5780,2447],{"class":58},[41,5782,5783],{"class":198},"       # take either side of the document\n",[41,5785,5786,5789,5792,5795,5797,5800],{"class":43,"line":73},[41,5787,5788],{"class":105},"cd",[41,5790,5791],{"class":58}," server",[41,5793,5794],{"class":47}," && ",[41,5796,2455],{"class":1265},[41,5798,5799],{"class":58}," run",[41,5801,5802],{"class":58}," docs:behaviour\n",[11,5804,5805],{},"The generator reads the merged test file, counts, and writes 545. It could not have written anything else. There is no judgement involved and therefore no opportunity for judgement to fail.",[23,5807,5809],{"id":5808},"three-ways-to-stop-it-recurring","Three ways to stop it recurring",[11,5811,5812],{},"The narrow question is what to do about this file. The honest answer is that there are three moves, and their costs are different.",[11,5814,5815,5818],{},[413,5816,5817],{},"Do not commit the artefact."," Generate it in the pipeline, publish it somewhere readers can find it, and let the repository hold only the tests it is derived from. This is the purest answer and it is the one we did not take, because the document earns its place in the repo. People read it in pull requests, and a diff that shows a behaviour appearing is worth seeing.",[11,5820,5821,5824,5825,5828],{},[413,5822,5823],{},"Teach git that the file is not text."," A line in ",[38,5826,5827],{},".gitattributes"," can point the file at a merge driver that resolves by taking either side, on the understanding that regeneration follows:",[31,5830,5833],{"className":5831,"code":5832,"language":122},[120],"docs\u002Fproduct\u002FAPP-BEHAVIOUR.md merge=generated\n",[38,5834,5832],{"__ignoreMap":36},[11,5836,5837],{},"This works. It also has a catch worth naming, because it is the sort of catch that quietly undoes tooling: the attribute is committed but the driver behind it is not, so every developer has to configure it once in their own clone. A convention that each person must remember to install is a convention that will be missing on somebody's machine on the day it matters.",[11,5839,5840,5843],{},[413,5841,5842],{},"Make the build refuse."," The pipeline already runs the generator and fails when its output differs from what was committed. That check is a single line in the workflow, it does not care whether you forgot to regenerate after a rebase or after a merge or after writing a test at four in the afternoon, and it cannot be missing from anyone's machine because it does not run on anyone's machine.",[11,5845,5846,5847,5850],{},"We keep all three considerations in view and lean on the third, for the reason we set out in ",[14,5848,5849],{"href":2254},"From convention to plugin",". A rule that lives in a document is a rule people will forget. A rule that lives in the build is a step they cannot skip by accident. The gate does not make the conflict less likely. It makes resolving the conflict wrongly impossible to ship.",[23,5852,933],{"id":932},[11,5854,5855],{},"Every merge conflict is a question git asks when it cannot see enough to answer. What this one exposed is that git can only ever see the characters in the file, and for a generated artefact the characters are downstream of a truth git has no access to. Ask it to reconcile them and you get a well formed lie.",[11,5857,5858],{},"So carry two questions into your own repository. For each file under version control, ask whether a human wrote it, or whether a program did. And for every file a program wrote, ask what happens on the day two branches regenerate it. If the answer is that somebody resolves the conflict by reading carefully and choosing the better looking side, you have a bug waiting for the afternoon when they are tired.",[11,5860,5861],{},"There is a larger thread running out of this. The two slices only collided because they were apart long enough to. That is a question about how often you integrate, and it is where this series goes next.",[348,5863,5864],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}",{"title":36,"searchDepth":62,"depth":62,"links":5866},[5867,5868,5869,5870,5871],{"id":5670,"depth":62,"text":5671},{"id":5708,"depth":62,"text":5709},{"id":5741,"depth":62,"text":5742},{"id":5808,"depth":62,"text":5809},{"id":932,"depth":62,"text":933},"2026-07-09T09:14:00.000Z","A pull request conflicted in a file neither author had typed a character into. A generated document recorded the line numbers of tests, both branches shifted them, and the correct value appeared on neither side. Git merges text, and some files carry no meaning in their text.",{},{"title":5651,"description":5873},"blog\u002Fthe-conflict-nobody-wrote",[533,371,373],"Bhqua7GHfniA9X2jHggXBimCphfcxYpEc5uGkJXurV4",{"id":5880,"title":5881,"author":6,"body":5882,"category":525,"date":6035,"description":6036,"draft":362,"extension":363,"image":364,"meta":6037,"navigation":178,"path":6038,"project":364,"readingMinutes":85,"seo":6039,"stem":6040,"tags":364,"__hash__":6041},"blog\u002Fblog\u002Fthe-dojo-way-deliberate-practice-for-software-developers.md","The Dojo Way: Deliberate Practice for Software Developers",{"type":8,"value":5883,"toc":6027},[5884,5887,5890,5894,5897,5900,5903,5907,5910,5916,5922,5928,5932,5935,5938,5970,5973,5977,5980,5983,5986,5990,5993,5996,5999,6003,6006,6012,6018,6024],[11,5885,5886],{},"In martial arts, a dojo is not just a training hall. It is a philosophy. A place where you practise the fundamentals until they become second nature. Where the emphasis is not on learning new techniques every week, but on mastering the ones you already know.",[11,5888,5889],{},"Software development could learn a lot from this approach.",[23,5891,5893],{"id":5892},"the-problem-with-learning-on-the-job","The Problem with Learning on the Job",[11,5895,5896],{},"Most developers learn primarily through their work. They pick up new frameworks when a project demands it. They learn about testing when a bug slips through. They discover the importance of clean architecture when a codebase becomes impossible to maintain.",[11,5898,5899],{},"This is learning through consequence, and while it works eventually, it is slow, painful, and expensive. You are essentially using production systems as your training ground.",[11,5901,5902],{},"Imagine a surgeon who only practised during real operations. Or a pilot who only trained during commercial flights. The idea sounds absurd, yet this is exactly how most software teams operate.",[23,5904,5906],{"id":5905},"what-deliberate-practice-means","What Deliberate Practice Means",[11,5908,5909],{},"Deliberate practice is a concept popularised by psychologist Anders Ericsson. It has three key characteristics:",[11,5911,5912,5915],{},[413,5913,5914],{},"It is focused."," You work on a specific skill, not everything at once. Today you practise writing clean functions. Tomorrow you practise refactoring legacy code. The day after, you work on test design.",[11,5917,5918,5921],{},[413,5919,5920],{},"It involves feedback."," You need someone or something to tell you what you are doing well and what needs improvement. In a dojo, that is your instructor. In software, it can be a mentor, a pair programming partner, or a well designed code review process.",[11,5923,5924,5927],{},[413,5925,5926],{},"It pushes you beyond your comfort zone."," If the exercise is easy, you are not learning. Deliberate practice should feel challenging. That discomfort is where growth happens.",[23,5929,5931],{"id":5930},"how-the-dojo-model-works","How the Dojo Model Works",[11,5933,5934],{},"Our workshops are built around these principles. Each session has a clear skill focus. Participants write code, get immediate feedback, and iterate. There are no slides. There is no sitting and listening for hours.",[11,5936,5937],{},"A typical dojo session looks like this:",[572,5939,5940,5946,5952,5958,5964],{},[575,5941,5942,5945],{},[413,5943,5944],{},"Introduction"," (10 minutes): The facilitator explains the concept and the exercise.",[575,5947,5948,5951],{},[413,5949,5950],{},"First attempt"," (30 minutes): Participants work through the problem, usually in pairs.",[575,5953,5954,5957],{},[413,5955,5956],{},"Review"," (15 minutes): The group discusses approaches, trade offs, and common mistakes.",[575,5959,5960,5963],{},[413,5961,5962],{},"Second attempt"," (30 minutes): Armed with new understanding, participants try again.",[575,5965,5966,5969],{},[413,5967,5968],{},"Reflection"," (15 minutes): What did you learn? What would you do differently?",[11,5971,5972],{},"The magic is in the second attempt. By the time you come back to the problem, you have heard other perspectives, identified your own blind spots, and formed a clearer mental model. The improvement between the first and second attempt is often dramatic.",[23,5974,5976],{"id":5975},"repetition-is-not-boring","Repetition Is Not Boring",[11,5978,5979],{},"One objection we hear regularly is \"why would I solve the same problem twice?\" This reveals a misunderstanding about what repetition does for skill development.",[11,5981,5982],{},"A musician does not play a piece once and move on. They play it hundreds of times, each time refining their technique, their timing, their expression. The goal is not to get through the piece. The goal is to internalise the patterns so deeply that they become automatic.",[11,5984,5985],{},"The same applies to software. When you practise test driven development on a simple problem, you are not learning that specific problem. You are building the neural pathways for a way of thinking. You are training your instincts. So that when you face a complex, high pressure situation in production code, the approach comes naturally.",[23,5987,5989],{"id":5988},"building-a-practice-culture","Building a Practice Culture",[11,5991,5992],{},"The most powerful thing about the dojo model is not what happens during the session. It is what happens afterwards. Teams that embrace deliberate practice start to see their daily work differently.",[11,5994,5995],{},"Code reviews become learning opportunities, not gatekeeping exercises. Pair programming becomes a natural way to share knowledge, not an awkward obligation. Retrospectives become genuine reflection, not a ritual to endure.",[11,5997,5998],{},"This cultural shift is what separates teams that grow from teams that simply accumulate experience. Experience without reflection is just repetition. Experience with deliberate reflection is how you build genuine expertise.",[23,6000,6002],{"id":6001},"starting-small","Starting Small",[11,6004,6005],{},"You do not need a formal programme to start practising deliberately. Here are three things any team can do this week:",[11,6007,6008,6011],{},[413,6009,6010],{},"Set aside one hour."," Pick a problem from an online kata catalogue. Work through it as a team, focusing on clean code rather than speed.",[11,6013,6014,6017],{},[413,6015,6016],{},"Review your own code from six months ago."," What would you do differently? What patterns do you see now that you missed then?",[11,6019,6020,6023],{},[413,6021,6022],{},"Pair on something unfamiliar."," Find the area of your codebase that nobody understands and explore it together. Ask questions. Draw diagrams. Build understanding.",[11,6025,6026],{},"The dojo is not a place. It is a mindset. And it starts with the decision to treat your craft seriously enough to practise it.",{"title":36,"searchDepth":62,"depth":62,"links":6028},[6029,6030,6031,6032,6033,6034],{"id":5892,"depth":62,"text":5893},{"id":5905,"depth":62,"text":5906},{"id":5930,"depth":62,"text":5931},{"id":5975,"depth":62,"text":5976},{"id":5988,"depth":62,"text":5989},{"id":6001,"depth":62,"text":6002},"2026-01-22T09:00:00.000Z","Most developers learn on the job, but the job rarely teaches you the fundamentals. The Dojo approach borrows from martial arts to create a structured space for deliberate practice.",{},"\u002Fblog\u002Fthe-dojo-way-deliberate-practice-for-software-developers",{"title":5881,"description":6036},"blog\u002Fthe-dojo-way-deliberate-practice-for-software-developers","m16K14UGjSqYUAbOQrKDko9pj_SrAuq8EnaTzG506pw",{"id":6043,"title":6044,"author":6,"body":6045,"category":359,"date":6478,"description":6479,"draft":362,"extension":363,"image":364,"meta":6480,"navigation":178,"path":6481,"project":4789,"readingMinutes":91,"seo":6482,"stem":6483,"tags":6484,"__hash__":6486},"blog\u002Fblog\u002Fthe-five-second-goodbye.md","The Five Second Goodbye",{"type":8,"value":6046,"toc":6472},[6047,6050,6053,6057,6064,6067,6071,6074,6191,6194,6204,6214,6224,6230,6233,6237,6243,6249,6252,6332,6339,6342,6455,6458,6464,6466,6469],[11,6048,6049],{},"EkoLite, our small real time backend, recently learned to shut down properly. On SIGINT or SIGTERM it closes the websocket, which takes the web server with it, then closes the Mongo connection, then exits. Stop taking requests before you drop the database underneath them. Tidy.",[11,6051,6052],{},"Then someone asked the obvious question. What if the close never finishes?",[23,6054,6056],{"id":6055},"the-hang-nobody-sees","The hang nobody sees",[11,6058,6059,6060,6063],{},"Both halves of that goodbye can stall. A Mongo driver can sit waiting on a server that vanished mid conversation. A websocket close waits for clients to finish their closing handshake, and a wedged client never does. If either promise refuses to settle, the line that says ",[38,6061,6062],{},"process.exit(0)"," is simply never reached.",[11,6065,6066],{},"Locally that looks like Ctrl+C doing nothing. In a container it is quieter and worse: SIGTERM, then silence, then the orchestrator loses patience and sends SIGKILL. The process does die, eventually, but you lose the clean exit code and any clue about what hung. The failure leaves no story behind.",[23,6068,6070],{"id":6069},"arm-the-deadline-first","Arm the deadline first",[11,6072,6073],{},"The fix is to start a timer before you start saying goodbye, and let whichever finishes first decide the exit:",[31,6075,6077],{"className":133,"code":6076,"language":135,"meta":36,"style":36},"const SHUTDOWN_GRACE_MS = 5000;\n\nconst deadline = setTimeout(() => {\n  console.error('shutdown timed out, exiting hard');\n  process.exit(1);\n}, SHUTDOWN_GRACE_MS);\n\nawait app.close();\nclearTimeout(deadline);\nprocess.exit(0);\n",[38,6078,6079,6093,6097,6116,6131,6145,6154,6158,6170,6178],{"__ignoreMap":36},[41,6080,6081,6083,6086,6088,6091],{"class":43,"line":44},[41,6082,142],{"class":81},[41,6084,6085],{"class":105}," SHUTDOWN_GRACE_MS",[41,6087,148],{"class":81},[41,6089,6090],{"class":105}," 5000",[41,6092,157],{"class":47},[41,6094,6095],{"class":43,"line":62},[41,6096,179],{"emptyLinePlaceholder":178},[41,6098,6099,6101,6104,6106,6109,6112,6114],{"class":43,"line":73},[41,6100,142],{"class":81},[41,6102,6103],{"class":105}," deadline",[41,6105,148],{"class":81},[41,6107,6108],{"class":1265}," setTimeout",[41,6110,6111],{"class":47},"(() ",[41,6113,1353],{"class":81},[41,6115,1275],{"class":47},[41,6117,6118,6121,6124,6126,6129],{"class":43,"line":85},[41,6119,6120],{"class":47},"  console.",[41,6122,6123],{"class":1265},"error",[41,6125,1314],{"class":47},[41,6127,6128],{"class":58},"'shutdown timed out, exiting hard'",[41,6130,1435],{"class":47},[41,6132,6133,6136,6139,6141,6143],{"class":43,"line":91},[41,6134,6135],{"class":47},"  process.",[41,6137,6138],{"class":1265},"exit",[41,6140,1314],{"class":47},[41,6142,5554],{"class":105},[41,6144,1435],{"class":47},[41,6146,6147,6149,6152],{"class":43,"line":97},[41,6148,1993],{"class":47},[41,6150,6151],{"class":105},"SHUTDOWN_GRACE_MS",[41,6153,1435],{"class":47},[41,6155,6156],{"class":43,"line":1358},[41,6157,179],{"emptyLinePlaceholder":178},[41,6159,6160,6162,6165,6168],{"class":43,"line":1390},[41,6161,3963],{"class":81},[41,6163,6164],{"class":47}," app.",[41,6166,6167],{"class":1265},"close",[41,6169,4993],{"class":47},[41,6171,6172,6175],{"class":43,"line":1407},[41,6173,6174],{"class":1265},"clearTimeout",[41,6176,6177],{"class":47},"(deadline);\n",[41,6179,6180,6183,6185,6187,6189],{"class":43,"line":1438},[41,6181,6182],{"class":47},"process.",[41,6184,6138],{"class":1265},[41,6186,1314],{"class":47},[41,6188,3796],{"class":105},[41,6190,1435],{"class":47},[11,6192,6193],{},"Four small decisions are hiding in those ten lines, and each one earns its place.",[11,6195,6196,6199,6200,6203],{},[413,6197,6198],{},"You do not need Promise.race."," It is tempting to race the close against a timeout promise, but ",[38,6201,6202],{},"process.exit"," inside the timer callback ends the process no matter what the awaited close is doing. A plain setTimeout is the whole mechanism. Racing promises would be a more elaborate way of saying the same thing.",[11,6205,6206,6209,6210,6213],{},[413,6207,6208],{},"The clearTimeout is about honesty, not liveness."," On the happy path the explicit ",[38,6211,6212],{},"exit(0)"," wins either way, so skipping the clearTimeout would still work. But cancelling the deadline states the intent: a clean close stands the guard down. Sloppiness that production happens to mask is still sloppiness, and we will come back to how a test can see it.",[11,6215,6216,6219,6220,6223],{},[413,6217,6218],{},"Pick the grace period relative to whoever kills you next."," Kubernetes gives you thirty seconds by default, ",[38,6221,6222],{},"docker stop"," gives you ten. Anything comfortably under that window means you own the failure, log it in your own words, and choose your exit code, rather than being SIGKILLed mid flush. Five seconds is generous for a socket and a database client.",[11,6225,6226,6229],{},[413,6227,6228],{},"Exit codes carry the story."," Zero means everything closed properly. One means we gave up waiting. Your orchestrator and your logs can now tell a clean stop from a hung one, which is the entire point of doing better than SIGKILL.",[11,6231,6232],{},"Two refinements round it off. A second signal should mean I meant it: a small flag makes the first signal graceful and the second an immediate hard exit, instead of re entering the handler and closing things twice. And a close that rejects, rather than hangs, wants a catch that logs and exits 1, so the last thing in the log is your sentence and not an unhandled rejection trace.",[23,6234,6236],{"id":6235},"the-part-you-can-test","The part you can test",[11,6238,6239,6240,6242],{},"Here is the uncomfortable bit. Those ten lines live in the boot file, the humble shell that reads config and starts the server, and the shell is untested on purpose. Signals, timers and ",[38,6241,6202],{}," are exactly the things a test runner cannot let you touch. Calling the real exit kills the test along with the process.",[11,6244,6245,6246,6248],{},"The move is the same one that tamed Mongo and the websocket in this codebase: put the awkward thing behind a nullable wrapper. A ",[38,6247,4807],{}," owns signals in, exit codes out, and the deadline timer. The real one delegates to the process. The nulled one lets a test simulate a signal, advance time by hand, and record exits in an output tracker instead of dying.",[11,6250,6251],{},"The shutdown policy then becomes a small class that takes anything closable plus that wrapper, and the payoff is easiest to see as two call sites. In production, the boot file wires the real world and steps back:",[31,6253,6255],{"className":133,"code":6254,"language":135,"meta":36,"style":36},"const app = App.create(config);\nconst server = await createServer(app);\nawait server.listen({ port: config.port, host: '0.0.0.0' });\n\nnew Shutdown(app, ProcessWrapper.create()).arm();\n",[38,6256,6257,6274,6290,6308,6312],{"__ignoreMap":36},[41,6258,6259,6261,6264,6266,6269,6271],{"class":43,"line":44},[41,6260,142],{"class":81},[41,6262,6263],{"class":105}," app",[41,6265,148],{"class":81},[41,6267,6268],{"class":47}," App.",[41,6270,3937],{"class":1265},[41,6272,6273],{"class":47},"(config);\n",[41,6275,6276,6278,6280,6282,6284,6287],{"class":43,"line":62},[41,6277,142],{"class":81},[41,6279,5791],{"class":105},[41,6281,148],{"class":81},[41,6283,4047],{"class":81},[41,6285,6286],{"class":1265}," createServer",[41,6288,6289],{"class":47},"(app);\n",[41,6291,6292,6294,6297,6300,6303,6306],{"class":43,"line":73},[41,6293,3963],{"class":81},[41,6295,6296],{"class":47}," server.",[41,6298,6299],{"class":1265},"listen",[41,6301,6302],{"class":47},"({ port: config.port, host: ",[41,6304,6305],{"class":58},"'0.0.0.0'",[41,6307,4097],{"class":47},[41,6309,6310],{"class":43,"line":85},[41,6311,179],{"emptyLinePlaceholder":178},[41,6313,6314,6316,6319,6322,6324,6327,6330],{"class":43,"line":91},[41,6315,823],{"class":81},[41,6317,6318],{"class":1265}," Shutdown",[41,6320,6321],{"class":47},"(app, ProcessWrapper.",[41,6323,3937],{"class":1265},[41,6325,6326],{"class":47},"()).",[41,6328,6329],{"class":1265},"arm",[41,6331,4993],{"class":47},[11,6333,6334,6335,6338],{},"Nobody calls anything after ",[38,6336,6337],{},"arm()",". From here the operating system is the caller, and the policy waits for it.",[11,6340,6341],{},"In a test, the same class gets the nulled process, and the test takes over every role that is normally unownable: who sends signals, when time passes, and what exit does.",[31,6343,6345],{"className":133,"code":6344,"language":135,"meta":36,"style":36},"const proc = ProcessWrapper.createNull();\nconst exits = proc.trackExits();\nnew Shutdown(closable, proc).arm();\n\nproc.simulateSignal('SIGINT');   \u002F\u002F the test plays the operating system\nproc.advanceTime(5000);          \u002F\u002F the test plays the clock\n\nexpect(exits.data).toEqual([{ code: 1 }]);  \u002F\u002F recorded, not executed\n",[38,6346,6347,6361,6378,6391,6395,6413,6430,6434],{"__ignoreMap":36},[41,6348,6349,6351,6353,6355,6357,6359],{"class":43,"line":44},[41,6350,142],{"class":81},[41,6352,5096],{"class":105},[41,6354,148],{"class":81},[41,6356,5101],{"class":47},[41,6358,5104],{"class":1265},[41,6360,4993],{"class":47},[41,6362,6363,6365,6368,6370,6373,6376],{"class":43,"line":62},[41,6364,142],{"class":81},[41,6366,6367],{"class":105}," exits",[41,6369,148],{"class":81},[41,6371,6372],{"class":47}," proc.",[41,6374,6375],{"class":1265},"trackExits",[41,6377,4993],{"class":47},[41,6379,6380,6382,6384,6387,6389],{"class":43,"line":73},[41,6381,823],{"class":81},[41,6383,6318],{"class":1265},[41,6385,6386],{"class":47},"(closable, proc).",[41,6388,6329],{"class":1265},[41,6390,4993],{"class":47},[41,6392,6393],{"class":43,"line":85},[41,6394,179],{"emptyLinePlaceholder":178},[41,6396,6397,6400,6403,6405,6408,6410],{"class":43,"line":91},[41,6398,6399],{"class":47},"proc.",[41,6401,6402],{"class":1265},"simulateSignal",[41,6404,1314],{"class":47},[41,6406,6407],{"class":58},"'SIGINT'",[41,6409,4656],{"class":47},[41,6411,6412],{"class":198},"\u002F\u002F the test plays the operating system\n",[41,6414,6415,6417,6419,6421,6424,6427],{"class":43,"line":97},[41,6416,6399],{"class":47},[41,6418,4842],{"class":1265},[41,6420,1314],{"class":47},[41,6422,6423],{"class":105},"5000",[41,6425,6426],{"class":47},");          ",[41,6428,6429],{"class":198},"\u002F\u002F the test plays the clock\n",[41,6431,6432],{"class":43,"line":1358},[41,6433,179],{"emptyLinePlaceholder":178},[41,6435,6436,6439,6442,6444,6447,6449,6452],{"class":43,"line":1390},[41,6437,6438],{"class":1265},"expect",[41,6440,6441],{"class":47},"(exits.data).",[41,6443,4744],{"class":1265},[41,6445,6446],{"class":47},"([{ code: ",[41,6448,5554],{"class":105},[41,6450,6451],{"class":47}," }]);  ",[41,6453,6454],{"class":198},"\u002F\u002F recorded, not executed\n",[11,6456,6457],{},"The class under test is byte for byte the one that runs in production. Only the world it was handed changed, and no code between the two ever asks which world it got. Every decision above turns into a plain assertion in this style. Deliver a signal, resolve the close, expect exit 0. Deliver a signal, leave the close hanging, advance time past the grace, expect exit 1. Deliver two signals, expect the second to exit immediately.",[11,6459,6460,6461,6463],{},"And the nulled process pays an unexpected dividend. A real process stops existing at ",[38,6462,6212],{},", so it can never testify about the timer you forgot to cancel. The nulled one outlives the exit. Resolve the close, advance time past the grace anyway, and assert that exactly one exit was recorded. The missing clearTimeout, invisible in production, fails a test.",[23,6465,5626],{"id":5625},[11,6467,6468],{},"When a policy worth testing grows inside an untested shell, do not test the shell. Extract the policy, wrap the part of the world it touches, and keep the shell to wiring. The shell stays humble, the policy gets a test for every sentence of its design, and the next person who wonders why the deadline is five seconds finds the answer written down twice, once in a test name and once in whoever kills you next.",[348,6470,6471],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":36,"searchDepth":62,"depth":62,"links":6473},[6474,6475,6476,6477],{"id":6055,"depth":62,"text":6056},{"id":6069,"depth":62,"text":6070},{"id":6235,"depth":62,"text":6236},{"id":5625,"depth":62,"text":5626},"2026-07-05T12:03:00.000Z","A shutdown that arms its own deadline so a hung close cannot strand the process, with the policy lifted out of the untested boot shell so every decision it makes can be tested.",{},"\u002Fblog\u002Fthe-five-second-goodbye",{"title":6044,"description":6479},"blog\u002Fthe-five-second-goodbye",[6485,3914,3001],"shutdown","ogSgcEH0LalwIwaM1n-7rsET9yecwEYve2sg8KcWRfA",{"id":6488,"title":6489,"author":6,"body":6490,"category":359,"date":6869,"description":6870,"draft":362,"extension":363,"image":364,"meta":6871,"navigation":178,"path":6872,"project":4789,"readingMinutes":97,"seo":6873,"stem":6874,"tags":6875,"__hash__":6877},"blog\u002Fblog\u002Fthe-framework-in-the-description-field.md","The Framework in the Description Field",{"type":8,"value":6491,"toc":6860},[6492,6498,6501,6532,6535,6539,6555,6566,6598,6609,6613,6616,6628,6631,6635,6642,6645,6763,6769,6773,6776,6791,6812,6816,6827,6830,6832,6835,6842,6849,6851,6854,6857],[11,6493,6494,6495,6497],{},"Our ",[38,6496,1029],{}," had been calling EkoLite \"a lightweight, real time backend framework\" for months. It is a fair description of the ambition. It was also, until recently, untrue in one precise sense: a framework is a thing you install, and ours could not be installed.",[11,6499,6500],{},"Two commands measure the distance between a sentence and the truth.",[31,6502,6504],{"className":2275,"code":6503,"language":2277,"meta":36,"style":36},"npm view ekolite      # 404, the name is free\nnpm pack --dry-run    # a couple of hundred files\n",[38,6505,6506,6519],{"__ignoreMap":36},[41,6507,6508,6510,6513,6516],{"class":43,"line":44},[41,6509,2455],{"class":1265},[41,6511,6512],{"class":58}," view",[41,6514,6515],{"class":58}," ekolite",[41,6517,6518],{"class":198},"      # 404, the name is free\n",[41,6520,6521,6523,6526,6529],{"class":43,"line":62},[41,6522,2455],{"class":1265},[41,6524,6525],{"class":58}," pack",[41,6527,6528],{"class":105}," --dry-run",[41,6530,6531],{"class":198},"    # a couple of hundred files\n",[11,6533,6534],{},"The first is fine, the name was ours to take. The second is the tell. With no files list, the tarball is a photograph of the whole repository: the tests, the docs, the demo, the continuous integration config, every source file. Nobody had ever consumed EkoLite as a dependency, and the tarball showed it. A framework packs the thing it built. A repository packs itself.",[23,6536,6538],{"id":6537},"the-build-that-built-nothing","The build that built nothing",[11,6540,6541,6542,6544,6545,794,6547,6550,6551,6554],{},"It got quieter and worse when we looked at what a consumer would actually load. ",[38,6543,1029],{}," pointed ",[38,6546,789],{},[38,6548,6549],{},"types"," at ",[38,6552,6553],{},"dist\u002Fserver\u002Findex.js"," and its declarations. Reasonable. Except the build never produced them.",[11,6556,6557,6558,6561,6562,6565],{},"The source imports its own modules with explicit extensions, ",[38,6559,6560],{},"import { App } from '.\u002Fapp.ts'",". That is a deliberate choice that makes the development runner happy, and it comes with a condition. TypeScript will only accept a ",[38,6563,6564],{},".ts"," in an import specifier when it is forbidden to emit:",[31,6567,6571],{"className":6568,"code":6569,"language":6570,"meta":36,"style":36},"language-jsonc shiki shiki-themes github-light github-dark","{\n  \"allowImportingTsExtensions\": true, \u002F\u002F the source says '.\u002Fapp.ts'\n  \"noEmit\": true                      \u002F\u002F so the compiler emits nothing\n}\n","jsonc",[38,6572,6573,6578,6586,6594],{"__ignoreMap":36},[41,6574,6575],{"class":43,"line":44},[41,6576,6577],{},"{\n",[41,6579,6580,6583],{"class":43,"line":62},[41,6581,6582],{},"  \"allowImportingTsExtensions\": true,",[41,6584,6585],{}," \u002F\u002F the source says '.\u002Fapp.ts'\n",[41,6587,6588,6591],{"class":43,"line":73},[41,6589,6590],{},"  \"noEmit\": true",[41,6592,6593],{},"                      \u002F\u002F so the compiler emits nothing\n",[41,6595,6596],{"class":43,"line":85},[41,6597,204],{},[11,6599,6600,6601,6604,6605,6608],{},"So the build script, ",[38,6602,6603],{},"tsc && vite build",", did two honest things and one absent one. It type checked the whole project. It built the browser demo. And it emitted no server at all, because the compiler was under orders not to. ",[38,6606,6607],{},"npm run build"," exited zero and produced nothing you could ship. The description field said framework. The build shipped a repository with a dead pointer at its centre.",[23,6610,6612],{"id":6611},"why-the-whole-suite-stayed-green","Why the whole suite stayed green",[11,6614,6615],{},"Here is the part that matters, because it is the same shape we keep meeting. None of this failed a single test.",[11,6617,6618,6619,6621,6622,6624,6625,6627],{},"Inside the repository every file is present. The ",[38,6620,6564],{}," sources resolve because they are right there. ",[38,6623,789],{}," points at a file that does not exist yet, but nothing inside the repo ever asks ",[38,6626,789],{}," to resolve, because inside the repo you import the source, not the package. The development server runs. The type check passes. The demo loads. Every green tick is telling you the truth about the code as seen from where the author is standing.",[11,6629,6630],{},"A packaging bug is invisible from inside the package. The author has all the pieces, so the author cannot feel the gap. The only person who can feel it is the one who receives the tarball and nothing else.",[23,6632,6634],{"id":6633},"the-decision-leave-the-repo","The decision: leave the repo",[11,6636,6637,6638,6641],{},"At this point there is a tempting move, and it is the one we did not make. Reach for a bundler. Point a tool at the entry, let it trace and inline and rewrite, and get a ",[38,6639,6640],{},"dist"," out the other end. It would have worked, and it would have buried the real question under a dependency we would then have to keep alive.",[11,6643,6644],{},"The decision was smaller and ruder. Do not trust the build. Prove the package the only way that means anything, by becoming the stranger. Pack the tarball, install it into a folder that has never seen this repository, and import it as someone who paid for it and read the README.",[31,6646,6648],{"className":133,"code":6647,"language":135,"meta":36,"style":36},"\u002F\u002F in an empty directory, well outside the repo\nimport { App } from 'ekolite';\n\nconst app = App.createNull();\napp.methods.define('sum', (a, b) => a + b);\nconsole.log(await app.methods.call('sum', [2, 3])); \u002F\u002F expect 5\n",[38,6649,6650,6655,6671,6675,6689,6724],{"__ignoreMap":36},[41,6651,6652],{"class":43,"line":44},[41,6653,6654],{"class":198},"\u002F\u002F in an empty directory, well outside the repo\n",[41,6656,6657,6660,6663,6666,6669],{"class":43,"line":62},[41,6658,6659],{"class":81},"import",[41,6661,6662],{"class":47}," { App } ",[41,6664,6665],{"class":81},"from",[41,6667,6668],{"class":58}," 'ekolite'",[41,6670,157],{"class":47},[41,6672,6673],{"class":43,"line":73},[41,6674,179],{"emptyLinePlaceholder":178},[41,6676,6677,6679,6681,6683,6685,6687],{"class":43,"line":85},[41,6678,142],{"class":81},[41,6680,6263],{"class":105},[41,6682,148],{"class":81},[41,6684,6268],{"class":47},[41,6686,5104],{"class":1265},[41,6688,4993],{"class":47},[41,6690,6691,6694,6697,6699,6702,6705,6707,6709,6712,6714,6716,6719,6721],{"class":43,"line":91},[41,6692,6693],{"class":47},"app.methods.",[41,6695,6696],{"class":1265},"define",[41,6698,1314],{"class":47},[41,6700,6701],{"class":58},"'sum'",[41,6703,6704],{"class":47},", (",[41,6706,14],{"class":1280},[41,6708,867],{"class":47},[41,6710,6711],{"class":1280},"b",[41,6713,1350],{"class":47},[41,6715,1353],{"class":81},[41,6717,6718],{"class":47}," a ",[41,6720,5315],{"class":81},[41,6722,6723],{"class":47}," b);\n",[41,6725,6726,6729,6732,6734,6736,6739,6742,6744,6746,6749,6752,6754,6757,6760],{"class":43,"line":97},[41,6727,6728],{"class":47},"console.",[41,6730,6731],{"class":1265},"log",[41,6733,1314],{"class":47},[41,6735,3963],{"class":81},[41,6737,6738],{"class":47}," app.methods.",[41,6740,6741],{"class":1265},"call",[41,6743,1314],{"class":47},[41,6745,6701],{"class":58},[41,6747,6748],{"class":47},", [",[41,6750,6751],{"class":105},"2",[41,6753,867],{"class":47},[41,6755,6756],{"class":105},"3",[41,6758,6759],{"class":47},"])); ",[41,6761,6762],{"class":198},"\u002F\u002F expect 5\n",[11,6764,6765,6766,6768],{},"That script is the whole test. If it prints ",[38,6767,5175],{}," on a machine that has never seen our source, EkoLite is a framework in fact. If it throws, the description field was writing cheques the build could not cash. We let that script, and not the green suite, decide.",[23,6770,6772],{"id":6771},"what-the-stranger-caught","What the stranger caught",[11,6774,6775],{},"Run from outside, the install caught two things the repo had been hiding in plain sight.",[11,6777,6778,6779,6782,6783,6786,6787,6790],{},"The first was a dependency in the wrong drawer. The websocket server is imported as a runtime value, ",[38,6780,6781],{},"import { WebSocketServer } from 'ws'",", but ",[38,6784,6785],{},"ws"," was listed under ",[38,6788,6789],{},"devDependencies",". Inside the repo that distinction is invisible, because development dependencies are installed, so the wrapper loads and every test runs. A consumer's install does not carry development dependencies. The first time their code reached the socket, it would throw on a module that was never delivered. The repo resolved it; the tarball dropped it; only the stranger could tell.",[11,6792,6793,6794,6796,6797,6800,6801,6804,6805,867,6808,6811],{},"The second was subtler and went to the types. Once the build actually emitted, we rewrote the ",[38,6795,6564],{}," specifiers to ",[38,6798,6799],{},".js"," on the way out, which the compiler does for the runtime files. It does not do it for the declaration files. So the shipped ",[38,6802,6803],{},".d.ts"," still imported ",[38,6806,6807],{},".\u002Fapp.ts",[38,6809,6810],{},".\u002Finfrastructure\u002Fwebsocket.ts",", files that live in our source and were never packed. The package ran and its types resolved to nothing, which is the worst kind of working: a consumer's editor would light up green and know nothing. The fix was a short pass over the declarations that finished the job the compiler started. The rule that fell out of it is one we will keep: a shipped declaration may only import a file the tarball actually carries.",[23,6813,6815],{"id":6814},"the-build-we-kept","The build we kept",[11,6817,6818,6819,6822,6823,6826],{},"The mechanism underneath is deliberately small. The compiler emits. A flag rewrites ",[38,6820,6821],{},".\u002Fx.ts"," to ",[38,6824,6825],{},".\u002Fx.js"," in the runtime output. A dozen lines rewrite the same in the declarations. The demo moved out of the folder the compiled client library needed, so the two stopped fighting over one name. A files list cut the tarball from the entire repository down to the build, the README and the licence.",[11,6828,6829],{},"No bundler. The stance under the decision is that a build's one job is to tell the truth about what ships, and the smaller the build, the fewer places it can lie. Every tool you add between the source and the tarball is another thing that can be right about your repo and wrong about your package. We would rather have less machinery and one honest test than more machinery and a green tick we cannot cross examine.",[23,6831,5626],{"id":5625},[11,6833,6834],{},"If you publish anything, do this before you believe your own build.",[11,6836,6837,6838,6841],{},"Run ",[38,6839,6840],{},"npm pack --dry-run"," and read the list. Not the count, the list. If it contains your tests or your CI config or a lockfile, your tarball is a repository wearing a package's name.",[11,6843,6844,6845,6848],{},"Then, in a directory that has never met your source, run ",[38,6846,6847],{},"npm install .\u002Fyour-package-0.0.0.tgz"," and import the thing as the person who will pay for it. Call one real function. Compile one real type. The repository lies to you by being complete, because it hands you every file whether or not you shipped it. The tarball is the only artefact that tells the truth, and the only way to read it is to stop being the author for five minutes and be the stranger.",[23,6850,4767],{"id":4766},[11,6852,6853],{},"The thread runs through everything we do. A green suite proves the code understands itself. It does not prove the package does, or the deploy does, or the person on the other end can do the thing the description promised. Those are different vantages, and a test is only ever as honest as the place you run it from.",[11,6855,6856],{},"So we move the vantage. We test the shutdown from outside the process that is shutting down, the sync from the client that receives the data, and the framework from the folder that installs it. The word \"framework\" was in our description field for months. It moved into the world the day a stranger's empty directory could import it and get back a five.",[348,6858,6859],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .s4XuR, html code.shiki .s4XuR{--shiki-default:#E36209;--shiki-dark:#FFAB70}",{"title":36,"searchDepth":62,"depth":62,"links":6861},[6862,6863,6864,6865,6866,6867,6868],{"id":6537,"depth":62,"text":6538},{"id":6611,"depth":62,"text":6612},{"id":6633,"depth":62,"text":6634},{"id":6771,"depth":62,"text":6772},{"id":6814,"depth":62,"text":6815},{"id":5625,"depth":62,"text":5626},{"id":4766,"depth":62,"text":4767},"2026-07-08T17:28:39.000Z","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 into an empty folder.",{},"\u002Fblog\u002Fthe-framework-in-the-description-field",{"title":6489,"description":6870},"blog\u002Fthe-framework-in-the-description-field",[6876,3001,373],"packaging","P4S9L1MeLiPyL4QKSMirMWIMKYwNVfUgAPEIW_JnAog",{"id":4,"title":5,"author":6,"body":6879,"category":359,"date":360,"description":361,"draft":362,"extension":363,"image":364,"meta":7116,"navigation":178,"path":366,"project":367,"readingMinutes":97,"seo":7117,"stem":369,"tags":7118,"__hash__":374},{"type":8,"value":6880,"toc":7108},[6881,6885,6887,6889,6891,6937,6939,6941,6943,6948,6950,6952,7006,7008,7010,7012,7014,7016,7022,7024,7026,7028,7030,7032,7034,7070,7072,7076,7088,7090,7092,7094,7096,7098,7100,7106],[11,6882,6883,18],{},[14,6884,17],{"href":16},[11,6886,21],{},[23,6888,26],{"id":25},[11,6890,29],{},[31,6892,6893],{"className":33,"code":34,"language":35,"meta":36,"style":36},[38,6894,6895,6905,6913,6921,6925,6929],{"__ignoreMap":36},[41,6896,6897,6899,6901,6903],{"class":43,"line":44},[41,6898,48],{"class":47},[41,6900,52],{"class":51},[41,6902,55],{"class":47},[41,6904,59],{"class":58},[41,6906,6907,6909,6911],{"class":43,"line":62},[41,6908,65],{"class":51},[41,6910,55],{"class":47},[41,6912,70],{"class":58},[41,6914,6915,6917,6919],{"class":43,"line":73},[41,6916,76],{"class":51},[41,6918,55],{"class":47},[41,6920,82],{"class":81},[41,6922,6923],{"class":43,"line":85},[41,6924,88],{"class":58},[41,6926,6927],{"class":43,"line":91},[41,6928,94],{"class":58},[41,6930,6931,6933,6935],{"class":43,"line":97},[41,6932,100],{"class":51},[41,6934,55],{"class":47},[41,6936,106],{"class":105},[11,6938,109],{},[11,6940,112],{},[23,6942,116],{"id":115},[31,6944,6946],{"className":6945,"code":121,"language":122},[120],[38,6947,121],{"__ignoreMap":36},[11,6949,127],{},[11,6951,130],{},[31,6953,6954],{"className":133,"code":134,"language":135,"meta":36,"style":36},[38,6955,6956,6970,6984,6988,6998,7002],{"__ignoreMap":36},[41,6957,6958,6960,6962,6964,6966,6968],{"class":43,"line":44},[41,6959,142],{"class":81},[41,6961,145],{"class":105},[41,6963,148],{"class":81},[41,6965,151],{"class":47},[41,6967,154],{"class":105},[41,6969,157],{"class":47},[41,6971,6972,6974,6976,6978,6980,6982],{"class":43,"line":62},[41,6973,142],{"class":81},[41,6975,164],{"class":105},[41,6977,148],{"class":81},[41,6979,151],{"class":47},[41,6981,171],{"class":105},[41,6983,157],{"class":47},[41,6985,6986],{"class":43,"line":73},[41,6987,179],{"emptyLinePlaceholder":178},[41,6989,6990,6992,6994,6996],{"class":43,"line":85},[41,6991,184],{"class":81},[41,6993,187],{"class":47},[41,6995,190],{"class":81},[41,6997,193],{"class":47},[41,6999,7000],{"class":43,"line":91},[41,7001,199],{"class":198},[41,7003,7004],{"class":43,"line":97},[41,7005,204],{"class":47},[11,7007,207],{},[23,7009,211],{"id":210},[11,7011,214],{},[11,7013,217],{},[11,7015,220],{},[11,7017,223,7018,227,7020,231],{},[38,7019,226],{},[14,7021,230],{"href":16},[23,7023,235],{"id":234},[11,7025,238],{},[11,7027,241],{},[11,7029,244],{},[23,7031,248],{"id":247},[11,7033,251],{},[31,7035,7036],{"className":133,"code":254,"language":135,"meta":36,"style":36},[38,7037,7038,7042,7046,7058],{"__ignoreMap":36},[41,7039,7040],{"class":43,"line":44},[41,7041,261],{"class":198},[41,7043,7044],{"class":43,"line":62},[41,7045,266],{"class":198},[41,7047,7048,7050,7052,7054,7056],{"class":43,"line":73},[41,7049,271],{"class":47},[41,7051,154],{"class":105},[41,7053,276],{"class":81},[41,7055,279],{"class":58},[41,7057,157],{"class":47},[41,7059,7060,7062,7064,7066,7068],{"class":43,"line":85},[41,7061,271],{"class":47},[41,7063,171],{"class":105},[41,7065,276],{"class":81},[41,7067,279],{"class":58},[41,7069,157],{"class":47},[11,7071,296],{},[11,7073,299,7074,303],{},[38,7075,302],{},[31,7077,7078],{"className":33,"code":306,"language":35,"meta":36,"style":36},[38,7079,7080],{"__ignoreMap":36},[41,7081,7082,7084,7086],{"class":43,"line":44},[41,7083,302],{"class":51},[41,7085,55],{"class":47},[41,7087,317],{"class":58},[11,7089,320],{},[23,7091,324],{"id":323},[11,7093,327],{},[11,7095,330],{},[11,7097,333],{},[335,7099],{},[11,7101,7102,343,7104],{},[340,7103,342],{},[340,7105,346],{},[348,7107,350],{},{"title":36,"searchDepth":62,"depth":62,"links":7109},[7110,7111,7112,7113,7114,7115],{"id":25,"depth":62,"text":26},{"id":115,"depth":62,"text":116},{"id":210,"depth":62,"text":211},{"id":234,"depth":62,"text":235},{"id":247,"depth":62,"text":248},{"id":323,"depth":62,"text":324},{},{"title":5,"description":361},[371,372,373],{"id":7120,"title":7121,"author":6,"body":7122,"category":525,"date":7525,"description":7526,"draft":362,"extension":363,"image":364,"meta":7527,"navigation":178,"path":5618,"project":4789,"readingMinutes":1390,"seo":7528,"stem":7529,"tags":7530,"__hash__":7533},"blog\u002Fblog\u002Fthe-line-nothing-could-see.md","The Line Nothing Could See",{"type":8,"value":7123,"toc":7517},[7124,7127,7130,7133,7137,7153,7156,7160,7163,7177,7184,7187,7191,7194,7197,7203,7353,7359,7362,7366,7369,7372,7375,7419,7422,7495,7498,7501,7503,7506,7508,7511,7514],[11,7125,7126],{},"You learn more about an engineer from a refactor than from a feature. A feature has a spec to hide behind. A refactor has only judgement: what the author chose to touch, what they treated as fixed, and which questions they asked before deciding a line deserved to live.",[11,7128,7129],{},"That observation shapes how we work. The team works real issues on EkoLite, our small real time backend, and when a refactoring story comes back as a pull request we read it twice. Once for the code, and once for the reasoning the code implies. The gaps we find in the second reading are the curriculum. Not a curriculum we planned in advance, but the one the work itself just wrote.",[11,7131,7132],{},"Here is one of those readings.",[23,7134,7136],{"id":7135},"what-arrived","What arrived",[11,7138,7139,7140,7143,7144,7146,7147,7149,7150,7152],{},"The story asked for a cleanup pass over two small classes: the shutdown policy we wrote about in ",[14,7141,7142],{"href":6481},"The five second goodbye",", and the nullable ",[38,7145,4807],{}," it leans on. The PR that came back was careful work. A duplicated guard extracted into a helper. A long signal handler moved out of ",[38,7148,6337],{}," into a named method, byte for byte. A stub timer bug genuinely found and fixed: the fake clock fired timers in insertion order where the real ",[38,7151,4825],{}," fires them by deadline, and the fix came pinned with a new test, with no existing test modified. Every test green, the description honest about what changed and why.",[11,7154,7155],{},"Nothing wrong in the ship it sense. Which is exactly when the interesting review starts, because a refactor that works is finally quiet enough to show you how its author thinks.",[23,7157,7159],{"id":7158},"reading-the-reasoning","Reading the reasoning",[11,7161,7162],{},"Three of the changes, read together, told one story.",[11,7164,7165,7166,867,7169,7172,7173,7176],{},"The extracted guard took a parameter. The old code had two identical checks, each throwing its own message: ",[38,7167,7168],{},"advanceTime only available on null instance",[38,7170,7171],{},"simulateSignal only available on null instance",". The new helper reproduced each string exactly, so it needed the caller to pass its own method name, and the author typed that parameter as a strict union of the two names. You can see the reasoning: the messages are existing behaviour, existing behaviour is a contract, contracts must be preserved precisely, and strictness is free. What nobody asked is whether anything depends on those strings. Nothing does. There is not a single ",[38,7174,7175],{},"toThrow"," in the test file. The author preserved a constraint no one had set, and paid for it with a parameter where a caller can now pass the wrong name and ship an error that blames the wrong method.",[11,7178,7179,7180,7183],{},"The timer fix came with a cleanup line. After firing due timers, the new code compacted the internal array so fired and cancelled timers would not accumulate. Preventing growth is a good production instinct. But this array lives inside a test stub that exists for the length of a test and holds at most four timers, and the field it compacts is private, read by nothing except the loop that already skips dead entries. No test can fail because of that line. No test could ever fail because of that line. And the shape of it was revealing in its own right: the field was declared ",[38,7181,7182],{},"readonly",", so instead of reassigning a filtered array, the code splices the array with a spread of a filter of itself, a small contortion whose whole purpose is to mutate around a declaration the author did not feel allowed to question.",[11,7185,7186],{},"That was the pattern. Every choice optimised for visible tidiness: deduplicate, tighten types, clean up after yourself, match the interface exactly. All respectable instincts, and none of them wrong on their own. What was missing was one question, asked of any single line before keeping it or adding it: what can observe this? Which test is this line answerable to? Two of the four threads we opened on that PR were that one question wearing different clothes. The other two were its mirror image: a behaviour a test should have watched, and didn't.",[23,7188,7190],{"id":7189},"teaching-with-a-failing-test","Teaching with a failing test",[11,7192,7193],{},"So how do you teach that? Not with a comment that says remove this. A verdict teaches the fix and nothing else. The rule we hold ourselves to as reviewers is the same rule we hold the code to: every claim gets backed by a test.",[11,7195,7196],{},"If the claim is about shape, the proof is the existing suite staying green after the simpler version. That is what we wrote on the guard helper and the compaction line: here is the simpler form, here is how the neighbouring wrappers already solve the same problem, and the evidence that nothing breaks is the suite you already have.",[11,7198,7199,7200,7202],{},"If the claim is about behaviour, the reviewer owes a failing test. The timer fix, good as it was, kept one gap from the old loop: a timer created inside another timer's callback could never fire in the same ",[38,7201,4842],{}," call, although the real process would fire it. Rather than explain that in prose and hope, the review thread carried this:",[31,7204,7205],{"className":133,"code":5071,"language":135,"meta":36,"style":36},[38,7206,7207,7221,7235,7251,7255,7271,7283,7307,7311,7315,7327,7331,7349],{"__ignoreMap":36},[41,7208,7209,7211,7213,7215,7217,7219],{"class":43,"line":44},[41,7210,4602],{"class":1265},[41,7212,1314],{"class":47},[41,7214,5082],{"class":58},[41,7216,5085],{"class":47},[41,7218,1353],{"class":81},[41,7220,1275],{"class":47},[41,7222,7223,7225,7227,7229,7231,7233],{"class":43,"line":62},[41,7224,4623],{"class":81},[41,7226,5096],{"class":105},[41,7228,148],{"class":81},[41,7230,5101],{"class":47},[41,7232,5104],{"class":1265},[41,7234,4993],{"class":47},[41,7236,7237,7239,7241,7243,7245,7247,7249],{"class":43,"line":73},[41,7238,4623],{"class":81},[41,7240,5113],{"class":105},[41,7242,1340],{"class":81},[41,7244,5118],{"class":105},[41,7246,5121],{"class":47},[41,7248,4977],{"class":81},[41,7250,5126],{"class":47},[41,7252,7253],{"class":43,"line":85},[41,7254,179],{"emptyLinePlaceholder":178},[41,7256,7257,7259,7261,7263,7265,7267,7269],{"class":43,"line":91},[41,7258,5135],{"class":47},[41,7260,5138],{"class":1265},[41,7262,1314],{"class":47},[41,7264,5143],{"class":105},[41,7266,5085],{"class":47},[41,7268,1353],{"class":81},[41,7270,1275],{"class":47},[41,7272,7273,7275,7277,7279,7281],{"class":43,"line":97},[41,7274,5154],{"class":47},[41,7276,5157],{"class":1265},[41,7278,1314],{"class":47},[41,7280,5143],{"class":105},[41,7282,1435],{"class":47},[41,7284,7285,7287,7289,7291,7293,7295,7297,7299,7301,7303,7305],{"class":43,"line":1358},[41,7286,5168],{"class":47},[41,7288,5138],{"class":1265},[41,7290,1314],{"class":47},[41,7292,5175],{"class":105},[41,7294,5085],{"class":47},[41,7296,1353],{"class":81},[41,7298,5182],{"class":47},[41,7300,5157],{"class":1265},[41,7302,1314],{"class":47},[41,7304,5189],{"class":105},[41,7306,5192],{"class":47},[41,7308,7309],{"class":43,"line":1390},[41,7310,1894],{"class":47},[41,7312,7313],{"class":43,"line":1407},[41,7314,179],{"emptyLinePlaceholder":178},[41,7316,7317,7319,7321,7323,7325],{"class":43,"line":1438},[41,7318,5135],{"class":47},[41,7320,4842],{"class":1265},[41,7322,1314],{"class":47},[41,7324,5211],{"class":105},[41,7326,1435],{"class":47},[41,7328,7329],{"class":43,"line":1463},[41,7330,179],{"emptyLinePlaceholder":178},[41,7332,7333,7335,7337,7339,7341,7343,7345,7347],{"class":43,"line":1468},[41,7334,4729],{"class":1265},[41,7336,5224],{"class":47},[41,7338,4744],{"class":1265},[41,7340,5229],{"class":47},[41,7342,5143],{"class":105},[41,7344,867],{"class":47},[41,7346,5189],{"class":105},[41,7348,5238],{"class":47},[41,7350,7351],{"class":43,"line":1490},[41,7352,2019],{"class":47},[11,7354,7355,7356,756],{},"Paste it in, watch it fail, and the claim is no longer the reviewer's opinion. It is a fact about the code that the author can hold in their own hands. The comment then explains why it fails, in this case that the stub advances its clock to the end of the window before any callback runs, so the nested timer anchors its deadline in a future the loop has already passed. And it ends with a hint that points at the mechanism, not the diff: the stub has to move time the way the real clock does, step by step from one due timer to the next. The author writes the code. The test decides when they are done. What came back, and why the fix ran deeper than the one red test, is its own piece, ",[14,7357,7358],{"href":5643},"The clock that learned to walk",[11,7360,7361],{},"Two more habits keep the thread honest. When a finding predates the PR, the comment says so first, because blame is a fact to state and not a feeling to manage; two of our four findings were older than the branch, merely made visible by it. And a thread does not close when the test goes green. It closes when the author writes back what they found, in their own words. Green is the code understanding the change. The sentence is the person understanding it, and the sentence is the one we cannot ship without.",[23,7363,7365],{"id":7364},"the-exit-no-guard-could-see","The exit no guard could see",[11,7367,7368],{},"The nested timer was one of the two mirror image threads, the behaviour a test should have watched. The other was the double exit, and it tested this piece's own claim, that a thread closes on the sentence and not the green, harder than anything else on the branch.",[11,7370,7371],{},"The policy arms a deadline, then closes the app: a clean close exits 0, a deadline that fires first exits 1 and stops waiting. But the close that lost the race is still out there, and when it finally settles its handler runs anyway. The original code let it record a clean exit 0 on top of the hard exit 1 already taken. A shutdown that reports it timed out, and then reports it went cleanly. Both cannot be true, and older than the branch, it had stayed quiet because nothing had made it speak.",[11,7373,7374],{},"The author fixed it the honest way, a boolean that remembers an exit already happened, red test first, then green. Every habit in this piece says the thread closes there. It did not, because green was the code agreeing with the change, and the change was not yet the whole design. The flag guarded the two exits it was written beside. A second signal, the operator who means it, leaves on its own line, and that line never touched the flag. So a second red test, pasted into the same thread, ran straight to two exits again:",[31,7376,7378],{"className":133,"code":7377,"language":135,"meta":36,"style":36},"proc.simulateSignal('SIGTERM');\nproc.simulateSignal('SIGTERM');   \u002F\u002F exits hard, forgets to remember it\nresolveClose();                   \u002F\u002F the first close lands, a clean 0 on top\n",[38,7379,7380,7393,7408],{"__ignoreMap":36},[41,7381,7382,7384,7386,7388,7391],{"class":43,"line":44},[41,7383,6399],{"class":47},[41,7385,6402],{"class":1265},[41,7387,1314],{"class":47},[41,7389,7390],{"class":58},"'SIGTERM'",[41,7392,1435],{"class":47},[41,7394,7395,7397,7399,7401,7403,7405],{"class":43,"line":62},[41,7396,6399],{"class":47},[41,7398,6402],{"class":1265},[41,7400,1314],{"class":47},[41,7402,7390],{"class":58},[41,7404,4656],{"class":47},[41,7406,7407],{"class":198},"\u002F\u002F exits hard, forgets to remember it\n",[41,7409,7410,7413,7416],{"class":43,"line":73},[41,7411,7412],{"class":1265},"resolveClose",[41,7414,7415],{"class":47},"();                   ",[41,7417,7418],{"class":198},"\u002F\u002F the first close lands, a clean 0 on top\n",[11,7420,7421],{},"The comment named the shape, not the line. This class has four ways out, second signal, deadline, close resolves, close rejects, and the flag was checked at two of them. A guard that lives at each exit is a guard you can forget to add the next time you open one, which is precisely what had happened. So the answer was not a third check. It was to give the question a single home, a small gate the exits pass through instead of each remembering the rule for itself:",[31,7423,7425],{"className":133,"code":7424,"language":135,"meta":36,"style":36},"private exitOnce(code: number): void {\n  if (this.exited) {\n    return;\n  }\n  this.exited = true;\n  this.proc.exit(code);\n}\n",[38,7426,7427,7442,7454,7461,7465,7479,7491],{"__ignoreMap":36},[41,7428,7429,7432,7435,7438,7440],{"class":43,"line":44},[41,7430,7431],{"class":47},"private ",[41,7433,7434],{"class":1265},"exitOnce",[41,7436,7437],{"class":47},"(code: number): ",[41,7439,4848],{"class":81},[41,7441,1275],{"class":47},[41,7443,7444,7447,7449,7451],{"class":43,"line":62},[41,7445,7446],{"class":81},"  if",[41,7448,1334],{"class":47},[41,7450,5020],{"class":105},[41,7452,7453],{"class":47},".exited) {\n",[41,7455,7456,7459],{"class":43,"line":73},[41,7457,7458],{"class":81},"    return",[41,7460,157],{"class":47},[41,7462,7463],{"class":43,"line":85},[41,7464,1576],{"class":47},[41,7466,7467,7469,7472,7474,7477],{"class":43,"line":91},[41,7468,4855],{"class":105},[41,7470,7471],{"class":47},".exited ",[41,7473,4977],{"class":81},[41,7475,7476],{"class":105}," true",[41,7478,157],{"class":47},[41,7480,7481,7483,7486,7488],{"class":43,"line":97},[41,7482,4855],{"class":105},[41,7484,7485],{"class":47},".proc.",[41,7487,6138],{"class":1265},[41,7489,7490],{"class":47},"(code);\n",[41,7492,7493],{"class":43,"line":1358},[41,7494,204],{"class":47},[11,7496,7497],{},"The second signal and the deadline leave through it now, and the rule that used to be copied at each door lives in one. Then the thread closed, on the sentence. The author wrote back, in their own words, what the second signal had been doing before the gate: exiting at once without marking that it had exited, so the still pending close resolved a clean goodbye on top of the hard one. Nobody asked for a recital of the fix. They described the bug as they had come to see it, and that description is the thing no passing suite could have told us.",[11,7499,7500],{},"Set the two ends of the pull request beside each other. It opened with a line nothing could see, a cleanup that ran for no one, and it closed with an exit no guard could see, a way out the flag never covered. The same absence in opposite clothes: one a line the code did not need, the other a line the code did need and no test yet watched for. A refactor's quiet gift is that it moves the furniture enough for both to throw a shadow. Standing where the shadow falls, twice on the same thread if that is what it takes, is the review.",[23,7502,4767],{"id":4766},[11,7504,7505],{},"This is the whole method. Engineers work a real backlog on a real codebase, the review reads the reasoning and not just the diff, and the gap gets taught at the exact moment it costs something, with evidence the author can run rather than authority they have to take on trust. Nobody learns the missing question from a slide. They learn it from a failing test that would not exist if they had asked it.",[23,7507,5626],{"id":5625},[11,7509,7510],{},"Refactoring by form says: deduplicate, tighten, clean, align. Refactoring answerable to tests asks one question first: what can observe this line? If the answer is a test, name it, and let it hold the line in place. If the answer is nothing, the line is either missing its test or it does not belong, and the refactor phase is precisely when you are allowed to say which.",[11,7512,7513],{},"And if you review other people's refactors: back every claim with a test. Green suite for claims about shape, a failing test the author can paste in for claims about behaviour. The moment your review can be run instead of believed, it stops being a judgement and becomes a conversation about the code, which is the only conversation that was ever worth having.",[348,7515,7516],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":36,"searchDepth":62,"depth":62,"links":7518},[7519,7520,7521,7522,7523,7524],{"id":7135,"depth":62,"text":7136},{"id":7158,"depth":62,"text":7159},{"id":7189,"depth":62,"text":7190},{"id":7364,"depth":62,"text":7365},{"id":4766,"depth":62,"text":4767},{"id":5625,"depth":62,"text":5626},"2026-07-07T22:43:00.000Z","Reviewing the reasoning under a refactor, not just the diff. Asking of every line what can observe it, and backing each review claim with a test the author can run.",{},{"title":7121,"description":7526},"blog\u002Fthe-line-nothing-could-see",[7531,5647,7532,3001],"code-review","teaching","t1aoutAO-cm-jSg9mW1XnHxxC67DOuBypW-rhwJwUqQ",{"id":7535,"title":7536,"author":4153,"body":7537,"category":525,"date":7675,"description":7676,"draft":362,"extension":363,"image":364,"meta":7677,"navigation":178,"path":7678,"project":7679,"readingMinutes":97,"seo":7680,"stem":7681,"tags":7682,"__hash__":7684},"blog\u002Fblog\u002Fthe-website-that-told-the-same-story-twice.md","The Website That Told the Same Story Twice",{"type":8,"value":7538,"toc":7669},[7539,7542,7545,7548,7552,7555,7558,7561,7565,7568,7595,7598,7628,7631,7635,7638,7641,7644,7647,7651,7654,7657,7660,7663,7666],[11,7540,7541],{},"We had a feeling about our own website, and a feeling is a hard thing to act on.",[11,7543,7544],{},"The home page and the story page felt like the same story told twice. Read one, then the other, and you met Nigeria again, the open source tools again, the institute we are building again. Nothing was wrong in isolation. Together they sagged. But when you try to fix a thing by feel, every cut is an argument about taste, and taste is slow and personal and easy to defend against. We wanted something firmer than a feeling. We wanted evidence.",[11,7546,7547],{},"So we did what we would do with any tangle in a codebase. We stopped reading the words as words, and started treating them as data.",[23,7549,7551],{"id":7550},"content-is-data-wearing-a-nice-outfit","Content is data wearing a nice outfit",[11,7553,7554],{},"A web page looks like prose. Underneath, it is structure. Our pages are built from blocks: a hero, a section of text, a grid, a timeline. Each block is really a small record. It has a type, a position, a heading, and a job to do. Seen that way, a page is not an essay. It is a list of rows.",[11,7556,7557],{},"That shift is the whole trick. Once content is data, you can ask questions of it that you cannot ask of prose. You can count. You can group. You can find the thing that appears where it should not. A vague sense of repetition becomes a query with a yes or no answer.",[11,7559,7560],{},"The unit we were missing had no name on the page. We called it a beat. A beat is a single idea the site is trying to land. Our roots in Nigeria are a beat. Our open source engine is a beat. The institute we are growing into is a beat. A section is just one way of presenting a beat, and the same beat can be presented on more than one page. That last sentence is where the trouble was hiding.",[23,7562,7564],{"id":7563},"a-throwaway-database-gone-by-lunch","A throwaway database, gone by lunch",[11,7566,7567],{},"We sketched a tiny database. Four tables, fifteen lines of schema, the kind of thing you delete the moment it has answered you. One table for pages, one for beats, one for the sections on each page, and one join table to connect them. The join table is the quiet hero here. It records which beats each section is telling, and at what depth, lite or full.",[31,7569,7573],{"className":7570,"code":7571,"language":7572,"meta":36,"style":36},"language-sql shiki shiki-themes github-light github-dark","CREATE TABLE page    (id, slug, job);\nCREATE TABLE beat    (id, name, owner_page);   -- where this idea is told in full\nCREATE TABLE section (id, page_id, position, heading);\nCREATE TABLE section_beat (section_id, beat_id, depth);  -- the join\n","sql",[38,7574,7575,7580,7585,7590],{"__ignoreMap":36},[41,7576,7577],{"class":43,"line":44},[41,7578,7579],{},"CREATE TABLE page    (id, slug, job);\n",[41,7581,7582],{"class":43,"line":62},[41,7583,7584],{},"CREATE TABLE beat    (id, name, owner_page);   -- where this idea is told in full\n",[41,7586,7587],{"class":43,"line":73},[41,7588,7589],{},"CREATE TABLE section (id, page_id, position, heading);\n",[41,7591,7592],{"class":43,"line":85},[41,7593,7594],{},"CREATE TABLE section_beat (section_id, beat_id, depth);  -- the join\n",[11,7596,7597],{},"Then we typed our two pages into it, every section, every beat each section carried. Tedious for ten minutes. Worth it. Because now the question we had been circling for an hour was one short query.",[31,7599,7601],{"className":7570,"code":7600,"language":7572,"meta":36,"style":36},"SELECT beat, count(distinct page_id) AS pages\nFROM section_beat JOIN section USING (section_id)\nWHERE depth = 'full'\nGROUP BY beat\nHAVING pages > 1;\n",[38,7602,7603,7608,7613,7618,7623],{"__ignoreMap":36},[41,7604,7605],{"class":43,"line":44},[41,7606,7607],{},"SELECT beat, count(distinct page_id) AS pages\n",[41,7609,7610],{"class":43,"line":62},[41,7611,7612],{},"FROM section_beat JOIN section USING (section_id)\n",[41,7614,7615],{"class":43,"line":73},[41,7616,7617],{},"WHERE depth = 'full'\n",[41,7619,7620],{"class":43,"line":85},[41,7621,7622],{},"GROUP BY beat\n",[41,7624,7625],{"class":43,"line":91},[41,7626,7627],{},"HAVING pages > 1;\n",[11,7629,7630],{},"In plain words: show me every idea we are telling in full on more than one page. The database did not have feelings about our website. It returned three rows. Our Nigerian roots. Our open source engine. The institute we are building. Each one told fully, twice. The repetition we had felt was now three named things on a screen, and you cannot argue with three named things the way you can argue with a feeling.",[23,7632,7634],{"id":7633},"the-fix-has-an-old-name","The fix has an old name",[11,7636,7637],{},"What we had was a normalisation problem, the same one that haunts any database where the same fact is stored in two places. When a fact lives twice, the two copies drift, they contradict, and the reader, or the program, stops trusting either. The cure is old and simple. Store each fact once, in one home. Everywhere else, point to it.",[11,7639,7640],{},"So we gave every beat a single home. The roots, the engine, the longer arc of the institute belong to the story page, the place a reader goes when they want the full account. The home page keeps only a light touch of each, a single line and a link, and spends its space on the job only it can do, which is to say what we offer and show people the door they came for.",[11,7642,7643],{},"A second query, almost the same as the first, then told us which sections to cut. Any section telling a beat in full on a page that does not own it. Three sections on the home page lit up. We removed them. The home page went from nine sections to six and got faster to read and easier to act on. The story page kept the depth and stopped competing with the front door for the same words.",[11,7645,7646],{},"We did not design that outcome by argument. We let the structure show us, and we followed it.",[23,7648,7650],{"id":7649},"why-a-school-would-bother","Why a school would bother",[11,7652,7653],{},"We could have moved some paragraphs around until it felt better. It would have taken the same afternoon. The reason we reached for code instead is the reason we exist as a school.",[11,7655,7656],{},"We teach a way of working, not a stack of facts. And the habit underneath this small job is one of the most useful a builder can own. Make the implicit explicit. When something is hard to reason about, change its form until it is easy. A wall of prose is hard to reason about. A table is easy. The skill is not knowing SQL. It is recognising that a question about words could become a question about rows, and being willing to spend ten honest minutes building the thing that answers it.",[11,7658,7659],{},"That habit settles arguments that taste cannot. It replaces the loudest voice in the room with a result anyone can reproduce. It is, quietly, a fairer way to make decisions, and we think a school that builds technology for social change should model fair decisions in the small things as well as the large ones.",[11,7661,7662],{},"We also do this in the open on purpose. This website is part of how we earn trust, from the communities we build with and from the people who fund the work. Showing the working, including the throwaway database we deleted before lunch, is more honest proof of how we think than any claim we could write about ourselves. We are building a coding institute, and we are building it where the working can be seen.",[11,7664,7665],{},"The website told the same story twice. We did not talk ourselves out of the feeling, and we did not act on it blind. We gave it a shape we could question, and then we listened to the answer.",[348,7667,7668],{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":36,"searchDepth":62,"depth":62,"links":7670},[7671,7672,7673,7674],{"id":7550,"depth":62,"text":7551},{"id":7563,"depth":62,"text":7564},{"id":7633,"depth":62,"text":7634},{"id":7649,"depth":62,"text":7650},"2026-06-06","Our home and story pages felt like one story stretched across two screens. Rather than argue about it by taste, we modelled our own words as data, asked a small database one question, and let it show us exactly what we were repeating.",{},"\u002Fblog\u002Fthe-website-that-told-the-same-story-twice","The Website",{"title":7536,"description":7676},"blog\u002Fthe-website-that-told-the-same-story-twice",[373,7683,3566,7532],"content","biF4ScKf0bzWvioazfVWT_aHiA5m-qHc0SXUInvtGQE",{"id":7686,"title":7687,"author":6,"body":7688,"category":3906,"date":4785,"description":7874,"draft":362,"extension":363,"image":364,"meta":7875,"navigation":178,"path":7876,"project":4789,"readingMinutes":91,"seo":7877,"stem":7878,"tags":7879,"__hash__":7880},"blog\u002Fblog\u002Fthe-words-we-work-in.md","The Words We Work In",{"type":8,"value":7689,"toc":7866},[7690,7693,7696,7700,7710,7728,7732,7735,7747,7762,7774,7778,7784,7790,7799,7803,7819,7829,7838,7842,7851,7855,7861,7863],[11,7691,7692],{},"Open any review here for long enough and the same handful of words turn up. Nullable, embedded stub, stub object, output tracking. They are not ours by birth. They come from James Shore's pattern language for testing without mocks, and we lean on it because the problems in EkoLite keep arriving in exactly its shapes. What we have done is ground each word in our own code, so that when one of them lands in a review it points at a real line and not at an idea.",[11,7694,7695],{},"A shared word does a quiet, large thing. It turns a review from a difference of taste into a conversation about the code. So here is the short dictionary, grouped the way the ideas actually sit, with the place each one lives in EkoLite.",[23,7697,7699],{"id":7698},"the-wrapper-and-its-switch","The wrapper and its switch",[11,7701,7702,7705,7706,7709],{},[413,7703,7704],{},"Infrastructure wrapper."," The one class that owns a single piece of the outside world. ",[38,7707,7708],{},"WebSocketWrapper"," owns the socket layer, the Mongo wrapper owns the database, the client socket owns the browser's connection. Nothing else in the app talks to those systems directly. The wrapper gives the rest of the code a clean view and keeps the mess in one place.",[11,7711,7712,7715,7716,7719,7720,7723,7724,7727],{},[413,7713,7714],{},"Nullable."," An infrastructure wrapper with an off switch. It is built one of two ways, ",[38,7717,7718],{},"create()"," for the real thing and ",[38,7721,7722],{},"createNull()"," for the version that behaves the same but does not reach the world. It is not a test double. It is production code that can be switched off, which is why a nulled wrapper is also useful for warming a cache or running a dry run, not only in tests. ",[38,7725,7726],{},"WebSocketWrapper.createNull()"," is the one we have been living in.",[23,7729,7731],{"id":7730},"the-three-fakes-which-is-where-everyone-trips","The three fakes, which is where everyone trips",[11,7733,7734],{},"These three sound alike and are constantly mixed up. The distinctions are most of the value.",[11,7736,7737,7740,7741,7743,7744,7746],{},[413,7738,7739],{},"Embedded stub."," The in file stand in for the third party code beneath your wrapper. It has no off switch of its own. It is the off itself. When you call ",[38,7742,7722],{},", this is the thing wired in underneath, so the wrapper's real logic runs against something that does not touch the network. In EkoLite the embedded stub for the socket layer is the in memory stand in for the ",[38,7745,6785],{}," library: the null connection source and the null socket. They are dumb and permanently off. The wrapper above them is the clever part.",[11,7748,7749,7752,7753,7755,7756,7758,7759,756],{},[413,7750,7751],{},"Stub object."," A separate fake that stands in for your wrapper's own behaviour, rather than for the third party beneath it. The tell is that your production code has to special case it, an ",[38,7754,184],{}," choosing a real path or a test path. It passes its tests, and it quietly splits the real path from the tested path, so a test can stay green while the real thing is broken. It is the one to avoid, and the one we built by accident the day ",[38,7757,4671],{}," started asking ",[38,7760,7761],{},"if (entry.stub)",[11,7763,7764,7767,7768,7770,7771,7773],{},[413,7765,7766],{},"How to tell them apart."," Two questions settle it. Does it have a ",[38,7769,7722],{},"? Then it is the Nullable, the whole wrapper. Is it the dumb permanently off fake that ",[38,7772,7722],{}," injects? Then it is the embedded stub. And if your wrapper can tell, while it runs, whether it is holding the real thing or the fake, you have neither. You have a stub object.",[23,7775,7777],{"id":7776},"how-the-tests-see-the-wrapper","How the tests see the wrapper",[11,7779,7780,7783],{},[413,7781,7782],{},"Sociable test."," A unit test that lets the code under test run its real dependencies instead of swapping them out. It stays pointed at the one thing it is testing, but the dependencies underneath are the real ones.",[11,7785,7786,7789],{},[413,7787,7788],{},"Overlapping sociable test."," The reason it is worth the bother. Because the test runs the real dependency's real code, breaking that code breaks the test. The tests overlap like links in a chain, and the chain gives you the reach of broad tests without their cost. This is the property our disconnect test had lost, and the property we work to keep.",[11,7791,7792,7795,7796,7798],{},[413,7793,7794],{},"State based test."," A test that checks the output or the state the code produced, not which methods got called on the way. You assert that the client received this message, not that ",[38,7797,4671],{}," was invoked. It reads as arrange, act, assert, and it lets you change how the code works inside without rewriting the test.",[23,7800,7802],{"id":7801},"what-the-wrapper-offers-a-test","What the wrapper offers a test",[11,7804,7805,7808,7809,867,7812,794,7815,7818],{},[413,7806,7807],{},"Output tracking."," A way to see what the wrapper actually did, the writes it would have sent to the world, recorded as the thing the caller cares about rather than the raw call. ",[38,7810,7811],{},"trackConnections",[38,7813,7814],{},"trackDisconnections",[38,7816,7817],{},"trackMessages"," are ours. The rule that matters: tracking works the same on the real wrapper and the null one, because it is one wrapper. Tracking that only works on the null is the old stub object showing through.",[11,7820,7821,7824,7825,7828],{},[413,7822,7823],{},"Behavior simulation."," Methods that stand in for an event the outside world would have pushed at you, a client connecting, a message arriving, a socket closing. ",[38,7826,7827],{},"simulateConnection"," is ours. The discipline is that the simulated event and the real event run the same handler, so the thing you tested is the thing that ships.",[11,7830,7831,7834,7835,7837],{},[413,7832,7833],{},"Configurable responses."," When a nulled wrapper has to answer with something, you pass the answer you want into ",[38,7836,7722],{},", described in terms the caller understands rather than the raw shape of the wire. It keeps the test setup small and honest.",[23,7839,7841],{"id":7840},"where-the-real-proof-lives","Where the real proof lives",[11,7843,7844,7847,7848,7850],{},[413,7845,7846],{},"Narrow integration test."," The small, slow test that proves the wrapper genuinely talks to the real system, kept tight to that one boundary. Our ",[38,7849,6785],{}," and Fastify suites are these. Once a wrapper is properly Nullable, these shrink to one job, proving a real connection arrives and a real close fires, and everything about what the wrapper then does moves into the fast null tests.",[23,7852,7854],{"id":7853},"the-shortcut-for-the-code-on-top","The shortcut for the code on top",[11,7856,7857,7860],{},[413,7858,7859],{},"Fake it once you make it."," Most code is not a low level wrapper sitting on a third party library. It sits on other wrappers. To make that code Nullable you do not write a fresh stub, you build it from dependencies that are already nullable and pass the configuration down. You only hand write an embedded stub at the very bottom, where your code finally meets someone else's.",[335,7862],{},[11,7864,7865],{},"A glossary looks like the dry end of the work, the bit you write once and never read. We have found the opposite. The day the team shares these words is the day a review stops being one person's opinion against another's and becomes two people pointing at the same line, with the same name for what is wrong. The need taught us the words. Writing them down is just making sure the next person does not have to hit the same wall to learn them.",{"title":36,"searchDepth":62,"depth":62,"links":7867},[7868,7869,7870,7871,7872,7873],{"id":7698,"depth":62,"text":7699},{"id":7730,"depth":62,"text":7731},{"id":7776,"depth":62,"text":7777},{"id":7801,"depth":62,"text":7802},{"id":7840,"depth":62,"text":7841},{"id":7853,"depth":62,"text":7854},"The vocabulary we test with, Nullable, embedded stub, stub object, output tracking, each grounded in real code so a review points at a line, not at a difference of taste.",{},"\u002Fblog\u002Fthe-words-we-work-in",{"title":7687,"description":7874},"blog\u002Fthe-words-we-work-in",[3914,3001,373,7532],"wzbvD7NTTqqklsXs5AWb6qqW2DOC7TZ9Q5s2az9BRB8",{"id":7882,"title":7883,"author":4153,"body":7884,"category":525,"date":7675,"description":7958,"draft":362,"extension":363,"image":7959,"meta":7960,"navigation":178,"path":7961,"project":7679,"readingMinutes":85,"seo":7962,"stem":7963,"tags":7964,"__hash__":7967},"blog\u002Fblog\u002Fthe-workflow-is-the-lesson.md","The Workflow Is the Lesson",{"type":8,"value":7885,"toc":7951},[7886,7889,7892,7895,7899,7902,7905,7909,7912,7915,7919,7922,7925,7928,7931,7935,7938,7941,7945,7948],[11,7887,7888],{},"Where your content lives looks like a technical decision. For a school, it is a teaching one.",[11,7890,7891],{},"We teach a way of working that is keyboard first and code first. A workflow you can drive without reaching for a mouse, without hunting through a web interface, without waiting on a screen to load. We do this partly for speed and focus, and partly for accessibility. A workflow built from text, files, and the keyboard is one more people can reach, automate, and own.",[11,7893,7894],{},"For a while, the words on this site lived somewhere that quietly worked against that. This is the story of bringing them home, and the plan we are following to do it without stopping the work.",[23,7896,7898],{"id":7897},"why-a-cloud-cms-stopped-serving-us","Why a cloud CMS stopped serving us",[11,7900,7901],{},"Our writing has been living in a hosted content service, kept apart from the code that renders it. On paper that is a clean separation. In practice, for us, it added friction at every turn. Edits did not appear on our local site without a refresh. Drafts we could not see. A second system to keep in step with git. A way of working that leaned on a pointer and a browser tab rather than the keyboard.",[11,7903,7904],{},"The tool itself is capable. It is built for teams of authors publishing constantly without ever touching code, and that is a real and common need. It was not ours. We are a small, code first team, and every edit we make is already a commit waiting to happen.",[23,7906,7908],{"id":7907},"what-we-actually-want","What we actually want",[11,7910,7911],{},"Content as plain files. Living in git, beside the code, with one history and one source of truth. Editable in the same editor we write software in, reloading the moment we save. A workflow a new teammate can learn in an afternoon, because it is the same workflow they already use for code. Write, review, commit, push.",[11,7913,7914],{},"This is the lesson hiding inside the tooling. The tools we teach should be the tools we use. If we ask students to own their work through git, we should hold our own words the same way.",[23,7916,7918],{"id":7917},"the-plan-in-two-moves","The plan, in two moves",[11,7920,7921],{},"We have a live site to build and a deadline that does not care about elegance. So we are doing this in two honest steps rather than one perfect leap.",[11,7923,7924],{},"First, Nuxt Content. Our site already runs on Nuxt, so we are moving our writing into markdown files that Nuxt reads directly. Same framework, same components, no rebuild of the house. We get files in git, hot reload, and a single source of truth now, rather than next quarter.",[11,7926,7927],{},"Then, bit by bit, Astro and Keystatic. Once the live site is standing, we migrate piece by piece toward the end we actually want. Astro for a content site that ships almost no JavaScript, and Keystatic for a calm editor that writes those same files straight back into git. Authors who do not live in a terminal still get a gentle interface. Nothing leaves the repository. Publishing stays a commit.",[11,7929,7930],{},"We are naming the order plainly, because the order is the point. The cheap, reversible step first. The deeper change second, in small pieces we can review.",[23,7932,7934],{"id":7933},"doing-it-in-the-open","Doing it in the open",[11,7936,7937],{},"This post is the first piece of writing to live in the new way. It is a markdown file in our repository, and publishing it is a commit. We are documenting the decision here, including the parts that are unfinished, because a school that learns in the open should show its working, not only its conclusions.",[11,7939,7940],{},"We will be wrong about some of this. We will find something markdown handles badly, or a step that is harder than it looked. When we do, we will write that down too.",[23,7942,7944],{"id":7943},"what-we-are-really-practising","What we are really practising",[11,7946,7947],{},"None of this is about a framework. It is about ownership. Holding your work somewhere you can read it, version it, and pass it on. It is about curiosity. Asking what a tool is really for before reaching for it. And it is about humility. Being willing to move off a choice we made a year ago because we have learned more since.",[11,7949,7950],{},"The workflow is not the dull setup you rush through before the real teaching begins. The workflow is the lesson.",{"title":36,"searchDepth":62,"depth":62,"links":7952},[7953,7954,7955,7956,7957],{"id":7897,"depth":62,"text":7898},{"id":7907,"depth":62,"text":7908},{"id":7917,"depth":62,"text":7918},{"id":7933,"depth":62,"text":7934},{"id":7943,"depth":62,"text":7944},"We are moving our content out of a cloud CMS and into git. Here is why a school that teaches a keyboard first, code first way of working should practise it in the open, and the path we are taking to get there.","\u002Fimages\u002Fblog\u002Fworkflow-lesson-hero.png",{},"\u002Fblog\u002Fthe-workflow-is-the-lesson",{"title":7883,"description":7958},"blog\u002Fthe-workflow-is-the-lesson",[7965,533,7532,7966],"workflow","accessibility","c-pvmFuyJbOqd05-KMsc_FXqkJcOVbD1-1XWdXJBhbQ",{"id":7969,"title":7970,"author":6,"body":7971,"category":3557,"date":8167,"description":8168,"draft":362,"extension":363,"image":364,"meta":8169,"navigation":178,"path":8170,"project":367,"readingMinutes":73,"seo":8171,"stem":8172,"tags":8173,"__hash__":8176},"blog\u002Fblog\u002Ftwo-validators-one-gate.md","Two Validators, One Gate",{"type":8,"value":7972,"toc":8161},[7973,7979,7983,8006,8050,8061,8070,8074,8077,8084,8086,8089,8116,8122,8124,8127,8155,8158],[11,7974,7975,7976],{},"Here is a small thing we found by reading our own code carefully, and the decision it led to. It is a good example of a lesson that matters more than it looks: ",[413,7977,7978],{},"validating the same input twice is not twice the safety. It is one gate and one piece of drift waiting to happen.",[23,7980,7982],{"id":7981},"what-we-found","What we found",[11,7984,7985,7986,7989,7990,7993,7994,7997,7998,8001,8002,8005],{},"Our API validates request bodies in two places. First, every route declares a ",[413,7987,7988],{},"Fastify JSON Schema"," on its ",[38,7991,7992],{},"schema.body",". Fastify checks the incoming request against that schema and, if it is malformed, returns a 400 ",[413,7995,7996],{},"before the handler runs at all",". Second, several handlers ",[340,7999,8000],{},"also"," start with a ",[413,8003,8004],{},"Zod"," check:",[31,8007,8009],{"className":133,"code":8008,"language":135,"meta":36,"style":36},"const parsed = CreateParticipantSchema.safeParse(request.body);\nif (!parsed.success) return reply.badRequest(parsed.error.message);\n",[38,8010,8011,8029],{"__ignoreMap":36},[41,8012,8013,8015,8018,8020,8023,8026],{"class":43,"line":44},[41,8014,142],{"class":81},[41,8016,8017],{"class":105}," parsed",[41,8019,148],{"class":81},[41,8021,8022],{"class":47}," CreateParticipantSchema.",[41,8024,8025],{"class":1265},"safeParse",[41,8027,8028],{"class":47},"(request.body);\n",[41,8030,8031,8033,8035,8037,8040,8042,8044,8047],{"class":43,"line":62},[41,8032,184],{"class":81},[41,8034,1334],{"class":47},[41,8036,1415],{"class":81},[41,8038,8039],{"class":47},"parsed.success) ",[41,8041,1421],{"class":81},[41,8043,1424],{"class":47},[41,8045,8046],{"class":1265},"badRequest",[41,8048,8049],{"class":47},"(parsed.error.message);\n",[11,8051,8052,8053,8056,8057,8060],{},"Read those two facts together and something falls out: by the time that Zod line runs, Fastify has already rejected anything malformed. The ",[38,8054,8055],{},"if (!parsed.success)"," branch is ",[413,8058,8059],{},"unreachable",". It is dead code, a guard for a case that can no longer happen. We are validating the same body twice, and the second validation never fires.",[11,8062,8063,8064,8066,8067,8069],{},"We did not guess this. We grepped for every ",[38,8065,8025],{}," call site, confirmed each route also had a Fastify ",[38,8068,7992],{}," covering the same fields, and wrote it down so it would not be rediscovered from scratch.",[23,8071,8073],{"id":8072},"why-it-happened-and-why-it-is-not-free","Why it happened, and why it is not free",[11,8075,8076],{},"It happened the way most redundancy happens: two reasonable habits met in the middle. Fastify's schema is the framework's own way to validate, and it is declared where the route is declared. Zod is the popular TypeScript way to validate, and it is easy to reach for out of habit. Neither is wrong on its own. Together they describe the same input twice.",[11,8078,8079,8080,8083],{},"And two descriptions of one thing is not safety, it is a ",[413,8081,8082],{},"drift risk",". The day someone adds a field to the Fastify schema but not the Zod one (or the reverse), the two disagree, and now the truth about \"what is a valid participant\" depends on which validator you read. Redundant validation quietly turns one clear rule into two rules that can fall out of sync.",[23,8085,891],{"id":890},[11,8087,8088],{},"We kept one runtime gate and gave each tool the job it is actually good at:",[631,8090,8091,8097,8107],{},[575,8092,8093,8096],{},[413,8094,8095],{},"Fastify JSON Schema stays the runtime validator."," It sits at the framework boundary, runs before the handler, generates our OpenAPI document, and drives the response serializer. It earns its place.",[575,8098,8099,8102,8103,8106],{},[413,8100,8101],{},"Zod stays for type inference, not runtime checking."," Its real value here is ",[38,8104,8105],{},"z.infer\u003Ctypeof CreateParticipantSchema>",", which gives the handler a TypeScript type for the input from a single source. That is worth keeping.",[575,8108,8109,8115],{},[413,8110,8111,8112,8114],{},"The redundant ",[38,8113,8025],{}," branches go."," One caveat before deleting: confirm the check is not enforcing something the Fastify schema cannot express, a cross field rule, say. If it is, move that specific check rather than dropping it.",[11,8117,8118,8119],{},"So the shape we are moving to is: ",[413,8120,8121],{},"one validator at runtime, one source of types, no overlap.",[23,8123,933],{"id":932},[11,8125,8126],{},"The instinct that \"more validation is safer\" is wrong often enough to be worth unlearning. Two validators for one input are not a belt and braces; they are two belts that can be buckled to different notches. The discipline is:",[631,8128,8129,8135,8149],{},[575,8130,8131,8134],{},[413,8132,8133],{},"One source of truth per concern."," One thing decides what a valid request is.",[575,8136,8137,8140,8141,8144,8145,8148],{},[413,8138,8139],{},"Know why each layer exists."," We did not remove Zod because Zod is bad; we removed the ",[340,8142,8143],{},"duplicate runtime check"," because Fastify already does it, and we kept the ",[340,8146,8147],{},"type inference"," because nothing else does it. Reach for a tool for the job it is best at, not out of habit.",[575,8150,8151,8154],{},[413,8152,8153],{},"Find redundancy by reading, then write it down."," This was not caught by a test failing; it was caught by reading two facts and noticing they could not both matter. When you find dead code, record it so the next person does not have to rediscover it.",[11,8156,8157],{},"Deleting code you understand is one of the most satisfying things you will do as an engineer. This is a small, safe, well understood deletion, which is exactly why it makes a good first issue.",[348,8159,8160],{},"html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":36,"searchDepth":62,"depth":62,"links":8162},[8163,8164,8165,8166],{"id":7981,"depth":62,"text":7982},{"id":8072,"depth":62,"text":8073},{"id":890,"depth":62,"text":891},{"id":932,"depth":62,"text":933},"2026-07-07T22:06:46.000Z","Validating one request body twice, once with a Fastify schema and again with Zod, is not double safety. The second gate is unreachable, and two descriptions of one input are a drift risk. Keep one runtime validator and let Zod do the types.",{},"\u002Fblog\u002Ftwo-validators-one-gate",{"title":7970,"description":8168},"blog\u002Ftwo-validators-one-gate",[3565,8174,8175],"validation","typescript","hDWBF87nrZQfPWFjMInml1P4nrpE9PVgn5iW5NzLmfg",{"id":8178,"title":8179,"author":6,"body":8180,"category":359,"date":8438,"description":8439,"draft":362,"extension":363,"image":364,"meta":8440,"navigation":178,"path":8441,"project":367,"readingMinutes":97,"seo":8442,"stem":8443,"tags":8444,"__hash__":8445},"blog\u002Fblog\u002Fwe-are-all-doing-asynchronous-integration.md","We Are All Doing Asynchronous Integration",{"type":8,"value":8181,"toc":8430},[8182,8188,8191,8194,8198,8204,8210,8213,8217,8220,8223,8226,8229,8233,8242,8253,8323,8330,8336,8340,8343,8346,8352,8355,8358,8362,8365,8368,8401,8404,8408,8411,8414,8417,8420,8422,8428],[11,8183,8184,8185],{},"There is a sentence that will sound wrong for a moment and then will not stop being true. ",[413,8186,8187],{},"A continuous integration server does not give you continuous integration.",[11,8189,8190],{},"Continuous integration is a practice. It has three parts: bring the trunk into your work every few hours, never leave the trunk broken, and grow your release machinery alongside the product so that shipping is always possible. A server that runs your tests after you push automates the notification. It automates none of the practice. You can own the most elaborate pipeline in the industry and integrate once a fortnight, and many teams do.",[11,8192,8193],{},"Once you see the distinction, you have to look honestly at your own setup, and what you find is uncomfortable, because the tool most of us use nudges us towards the weaker of the two available practices without ever presenting it as a choice.",[23,8195,8197],{"id":8196},"two-ways-to-know-your-integration-worked","Two ways to know your integration worked",[11,8199,8200,8203],{},[413,8201,8202],{},"Synchronous integration."," You bring in the trunk, you run the full build on your machine, you push, and then you wait. You watch the build run in the pristine environment. You do not begin the next task. When it goes green, you are integrated and you move on. If it goes red, you are still holding the problem, it is the only thing you are holding, and the change that caused it is the change you were just looking at.",[11,8205,8206,8209],{},[413,8207,8208],{},"Asynchronous integration."," You push and carry on working. Some minutes later a machine tells you whether it worked. If it did not, you must put down the thing you have now started, page the old change back into your head, and fix it.",[11,8211,8212],{},"The second is what a forge gives you by default. Push, and a workflow starts. You do not wait, because nothing is asking you to.",[23,8214,8216],{"id":8215},"why-the-asynchronous-version-rots","Why the asynchronous version rots",[11,8218,8219],{},"It rots for reasons that are entirely about people and not at all about tooling.",[11,8221,8222],{},"The failure arrives when you have already begun something else. Stopping is expensive, so you finish the new thing first, and the trunk stays red for an hour. During that hour anyone who pulls gets broken code, and cannot tell your breakage from their own. When they run the suite, your failure is sitting there in the output, and their new failure hides in the noise beside it. So they leave theirs too.",[11,8224,8225],{},"Now the build has two problems and no owner, and the cost of fixing it has more than doubled, because the two failures interact and nobody knows which came first. The next person to integrate sees a red trunk and concludes, correctly, that fixing it is not their job. Broken becomes the resting state. Teams reach the point where a red build carries no information at all, and then the pipeline has become an expensive way of generating an email nobody reads.",[11,8227,8228],{},"None of that is inevitable. A disciplined team can integrate asynchronously and keep the trunk green by running the full build locally before pushing and by dropping everything the moment a build breaks. But notice what that discipline consists of: doing, by force of will, everything that synchronous integration would have made unavoidable. The synchronous version does not require more virtue. It requires less, because it does not present you with the opportunity to walk away.",[23,8230,8232],{"id":8231},"what-that-means-for-the-dojo","What that means for the Dojo",[11,8234,8235,8236,8238,8239,8241],{},"We have a workflow called ",[38,8237,2749],{},". It runs the server tests, the web tests, the type checks, the linter, a Playwright smoke suite, and the documentation gates. When it passes on ",[38,8240,789],{},", a second workflow triggers a deploy. It is a good pipeline and we are fond of it.",[11,8243,8244,8245,867,8247,1065,8250,1340],{},"It is also asynchronous integration, top to bottom. Nothing in it asks anyone to wait. And reading it closely turns up something sharper. Its push trigger fires on ",[38,8246,789],{},[38,8248,8249],{},"develop",[38,8251,8252],{},"feature\u002F**",[31,8254,8256],{"className":33,"code":8255,"language":35,"meta":36,"style":36},"on:\n  push:\n    branches: [main, develop, 'feature\u002F**']\n  pull_request:\n    types: [opened, synchronize, reopened, closed]\n",[38,8257,8258,8264,8271,8290,8297],{"__ignoreMap":36},[41,8259,8260,8262],{"class":43,"line":44},[41,8261,3075],{"class":105},[41,8263,3078],{"class":47},[41,8265,8266,8269],{"class":43,"line":62},[41,8267,8268],{"class":51},"  push",[41,8270,3078],{"class":47},[41,8272,8273,8275,8277,8279,8281,8283,8285,8288],{"class":43,"line":73},[41,8274,3116],{"class":51},[41,8276,3093],{"class":47},[41,8278,789],{"class":58},[41,8280,867],{"class":47},[41,8282,8249],{"class":58},[41,8284,867],{"class":47},[41,8286,8287],{"class":58},"'feature\u002F**'",[41,8289,3099],{"class":47},[41,8291,8292,8295],{"class":43,"line":85},[41,8293,8294],{"class":51},"  pull_request",[41,8296,3078],{"class":47},[41,8298,8299,8301,8303,8306,8308,8311,8313,8316,8318,8321],{"class":43,"line":91},[41,8300,3104],{"class":51},[41,8302,3093],{"class":47},[41,8304,8305],{"class":58},"opened",[41,8307,867],{"class":47},[41,8309,8310],{"class":58},"synchronize",[41,8312,867],{"class":47},[41,8314,8315],{"class":58},"reopened",[41,8317,867],{"class":47},[41,8319,8320],{"class":58},"closed",[41,8322,3099],{"class":47},[11,8324,8325,8326,8329],{},"Our branches are named ",[38,8327,8328],{},"anastasis\u002Fdoj-38-phase2b-quota-enforcement",". That matches none of those patterns, so pushing a branch runs nothing at all.",[11,8331,8332,8333,8335],{},"Once a pull request exists, the ",[38,8334,8310],{}," event takes over and every subsequent push is built, which is a genuine safety net. But the window before that, the hours in which the work is actually being shaped and a mistake is cheapest to find, is unguarded. Whether our feedback arrives early or late is therefore settled by a habit rather than by a pipeline: it depends entirely on how soon we open the pull request. Ours tend to open when the slice is finished, which is the moment feedback is worth least.",[23,8337,8339],{"id":8338},"the-excuse-we-do-not-have","The excuse we do not have",[11,8341,8342],{},"Here is where it stops being comfortable.",[11,8344,8345],{},"The reason the practice tolerates asynchronous integration at all is slow builds. If confirming your integration takes half an hour, standing there watching it is untenable, and you are forced into the weaker practice while you work on making the build faster. Twenty minutes is roughly where waiting becomes indefensible. Ten minutes is the target worth defending, because at ten minutes waiting stops being a cost and becomes a pause: you make tea, you talk to your pair about the design, you look up from the keyboard and think about the shape of the thing you are building.",[11,8347,8348,8349,8351],{},"Our last dozen runs of ",[38,8350,2749],{}," took between two minutes forty and four minutes two.",[11,8353,8354],{},"We are inside the budget, with room to spare. The build is not the reason we integrate asynchronously. We integrate asynchronously because it is what the tool did when we were not paying attention.",[11,8356,8357],{},"What synchronous integration would actually look like for us is not exotic. Run the full build locally before pushing, the same checks the pipeline runs, in the same order. Push. Watch the run. Do not start the next slice until it is green. If it goes red, revert first and understand second.",[23,8359,8361],{"id":8360},"the-pristine-machine-and-why-it-exists","The pristine machine, and why it exists",[11,8363,8364],{},"The one thing the local build cannot do is prove the code works anywhere but on your machine. Forgotten files, packages installed months ago and never declared, environment variables living in your shell: these are invisible from inside. Historically teams kept a spare computer for exactly this, a clean environment where the build had to pass before an integration counted. Our runner is that machine.",[11,8366,8367],{},"It only works if the machine resembles yours. We already do this deliberately, and it is disguised as a landmine: PostgreSQL runs on port 5433 locally, and so the CI service maps it the same way.",[31,8369,8371],{"className":33,"code":8370,"language":35,"meta":36,"style":36},"services:\n  postgres:\n    ports:\n      - 5433:5432\n",[38,8372,8373,8380,8387,8394],{"__ignoreMap":36},[41,8374,8375,8378],{"class":43,"line":44},[41,8376,8377],{"class":51},"services",[41,8379,3078],{"class":47},[41,8381,8382,8385],{"class":43,"line":62},[41,8383,8384],{"class":51},"  postgres",[41,8386,3078],{"class":47},[41,8388,8389,8392],{"class":43,"line":73},[41,8390,8391],{"class":51},"    ports",[41,8393,3078],{"class":47},[41,8395,8396,8398],{"class":43,"line":85},[41,8397,2764],{"class":47},[41,8399,8400],{"class":58},"5433:5432\n",[11,8402,8403],{},"That line is a small act of respect for the principle. The pristine environment is only useful as an oracle if the difference between it and your laptop is nothing but the pristineness.",[23,8405,8407],{"id":8406},"when-two-builds-are-legitimate","When two builds are legitimate",[11,8409,8410],{},"Some checks genuinely cannot fit in ten minutes: load tests, soak tests, deep static analysis. The recognised answer is a two stage build. A fast commit build, synchronous, containing everything needed to prove the software works. A slower secondary build, asynchronous, carrying the rest.",[11,8412,8413],{},"We have this, though we did not plan it as such. Qodana runs as its own workflow, in parallel, and does not gate anything. That is a defensible secondary stage.",[11,8415,8416],{},"The warning attached to the pattern is worth repeating, because it is where the pattern usually gets abused. Most teams reach for a second stage as a place to hide a slow test suite rather than fix it, and a second stage is strictly worse than a fast first one, because the feedback it carries arrives after you have stopped caring. Prefer better tests to more stages. Ours are fast. That was work someone did, and it is why the honest practice is available to us at all.",[11,8418,8419],{},"The next question is what happens when the build does go red, and what it means that one of our checks has been quietly unable to break anything for months.",[335,8421],{},[11,8423,8424,343,8426],{},[340,8425,342],{},[340,8427,346],{},[348,8429,3280],{},{"title":36,"searchDepth":62,"depth":62,"links":8431},[8432,8433,8434,8435,8436,8437],{"id":8196,"depth":62,"text":8197},{"id":8215,"depth":62,"text":8216},{"id":8231,"depth":62,"text":8232},{"id":8338,"depth":62,"text":8339},{"id":8360,"depth":62,"text":8361},{"id":8406,"depth":62,"text":8407},"2026-07-09T09:46:00.000Z","A continuous integration server is not continuous integration. The forge tests your work after you have moved on, which is the fallback the practice permits when builds are slow. Our build takes three minutes, so we have no such excuse.",{},"\u002Fblog\u002Fwe-are-all-doing-asynchronous-integration",{"title":8179,"description":8439},"blog\u002Fwe-are-all-doing-asynchronous-integration",[371,372,973],"AkhikN9iIifGcbW_QXz4rbsuU4bcLbCr43iZP4i_7w8",{"id":8447,"title":8448,"author":6,"body":8449,"category":3557,"date":8553,"description":8554,"draft":362,"extension":363,"image":364,"meta":8555,"navigation":178,"path":8556,"project":364,"readingMinutes":85,"seo":8557,"stem":8558,"tags":364,"__hash__":8559},"blog\u002Fblog\u002Fwhy-good-software-starts-with-the-right-questions.md","Why Good Software Starts with the Right Questions",{"type":8,"value":8450,"toc":8546},[8451,8454,8457,8460,8464,8467,8470,8473,8477,8480,8483,8486,8490,8493,8499,8505,8511,8517,8523,8527,8530,8533,8537,8540,8543],[11,8452,8453],{},"Most software projects do not fail because of bad code. They fail because the team built the wrong thing. Or they built the right thing for the wrong reasons. Or they solved a problem nobody actually had.",[11,8455,8456],{},"This is not a new observation. Teams have been talking about the importance of requirements gathering for decades. And yet the pattern repeats. A client walks in with a solution already in mind. The team nods along, opens their laptops, and starts building. Six months later, everyone is frustrated.",[11,8458,8459],{},"So what goes wrong?",[23,8461,8463],{"id":8462},"the-problem-with-starting-at-the-solution","The Problem with Starting at the Solution",[11,8465,8466],{},"When someone says \"we need a mobile app\" or \"we need to migrate to microservices,\" they are usually describing a solution, not a problem. The real question is: what is the outcome you are trying to achieve?",[11,8468,8469],{},"A mobile app might be the answer. But it might not. Maybe the real issue is that field workers cannot access their data offline. Maybe the root cause is a poorly designed internal tool that nobody wants to use. Maybe the actual need is better notifications, not an entirely new platform.",[11,8471,8472],{},"You will never know unless you ask.",[23,8474,8476],{"id":8475},"what-good-discovery-looks-like","What Good Discovery Looks Like",[11,8478,8479],{},"Good discovery is not about filling out templates or running workshops for the sake of it. It is about creating the conditions for honest conversation.",[11,8481,8482],{},"That means sitting with the people who will actually use the software. Not just the stakeholders who commissioned it, but the people on the ground. The customer service team answering calls at midnight. The warehouse operator scanning barcodes in poor lighting. The finance team copying data between three different spreadsheets.",[11,8484,8485],{},"These conversations are where the real requirements live. Not in a boardroom, but in the day to day friction that people have learned to work around.",[23,8487,8489],{"id":8488},"questions-worth-asking","Questions Worth Asking",[11,8491,8492],{},"Here are some questions that consistently lead to better outcomes:",[11,8494,8495,8498],{},[413,8496,8497],{},"What does success look like in six months?"," This forces clarity. If the answer is vague, the project scope will be vague too.",[11,8500,8501,8504],{},[413,8502,8503],{},"What are you doing today that you wish you could stop doing?"," This reveals the actual pain points, not the imagined ones.",[11,8506,8507,8510],{},[413,8508,8509],{},"Who else is affected by this?"," Software rarely exists in isolation. Understanding the wider system is crucial.",[11,8512,8513,8516],{},[413,8514,8515],{},"What have you tried before?"," Past failures are full of lessons. Ignoring them means repeating them.",[11,8518,8519,8522],{},[413,8520,8521],{},"What would make this project not worth doing?"," This surfaces the constraints and deal breakers early, when they are cheapest to address.",[23,8524,8526],{"id":8525},"discovery-is-not-a-phase","Discovery Is Not a Phase",[11,8528,8529],{},"One of the most common mistakes is treating discovery as something you do once at the start and then move on from. In reality, discovery should be continuous. Every sprint, every review, every conversation with a user is an opportunity to learn something new.",[11,8531,8532],{},"The best teams we have worked with treat curiosity as a core discipline. They are not afraid to say \"we were wrong\" or \"we have learned something that changes our approach.\" They build in time to reflect, not just to build.",[23,8534,8536],{"id":8535},"why-this-matters-for-consulting","Why This Matters for Consulting",[11,8538,8539],{},"At EkoHacks, we have seen the difference that good discovery makes. Projects that start with genuine curiosity tend to deliver faster, cost less, and produce software that people actually want to use.",[11,8541,8542],{},"This is not about being slow or cautious. It is about being deliberate. The time you invest in understanding the problem properly is time you save later by not building the wrong thing.",[11,8544,8545],{},"If you take one thing away from this, let it be this: the quality of your software is directly proportional to the quality of your questions. Start there, and the rest follows.",{"title":36,"searchDepth":62,"depth":62,"links":8547},[8548,8549,8550,8551,8552],{"id":8462,"depth":62,"text":8463},{"id":8475,"depth":62,"text":8476},{"id":8488,"depth":62,"text":8489},{"id":8525,"depth":62,"text":8526},{"id":8535,"depth":62,"text":8536},"2026-01-15T09:00:00.000Z","Before writing a single line of code, the most impactful thing a software team can do is ask better questions. Discovery is not a phase you rush through. It is the foundation everything else rests on.",{},"\u002Fblog\u002Fwhy-good-software-starts-with-the-right-questions",{"title":8448,"description":8554},"blog\u002Fwhy-good-software-starts-with-the-right-questions","qpfWyYUSnsJwcSm42WOnAV_ihVpZJCavCxswOYwCoUI",1783594762569]