[{"data":1,"prerenderedAt":1445},["ShallowReactive",2],{"post-\u002Fblog\u002Fwhat-a-null-cannot-see":3,"blog-all":915},{"id":4,"title":5,"author":6,"body":7,"category":897,"date":898,"description":899,"draft":900,"extension":901,"image":902,"meta":903,"navigation":109,"path":904,"project":905,"readingMinutes":307,"seo":906,"series":907,"seriesOrder":907,"stem":908,"tags":909,"__hash__":914},"blog\u002Fblog\u002Fwhat-a-null-cannot-see.md","What a Null Cannot See","EkoHacks Team",{"type":8,"value":9,"toc":887},"minimark",[10,23,30,35,45,150,162,166,173,311,314,322,325,329,340,347,353,359,363,370,373,379,382,386,442,445,524,527,531,534,660,671,675,682,787,797,839,862,866,869,883],[11,12,13,14,18,19,22],"p",{},"We spent one evening giving every wrapper in the EkoHacks CLI a contract list: one list of behaviour that the unit file runs on the wrapper's ",[15,16,17],"code",{},"createNull()"," and the integration file runs on its ",[15,20,21],{},"create()",". If the null and the real ever answer differently, one of those runs fails. The lists found four places where a null had drifted from the thing it stands in for, which is what they are for. They also found one place where a null cannot follow the real thing at all, and that one is worth a post, because the reason is not a bug.",[11,24,25,26,29],{},"Everything below can be replayed from two commits in ",[15,27,28],{},"github.com\u002Fekohacks\u002Fcli","; the last section has the commands.",[31,32,34],"h2",{"id":33},"the-contract-in-two-lines","The contract in two lines",[11,36,37,38,41,42,44],{},"A contract list is a function that takes a ",[15,39,40],{},"make"," and registers tests against whatever ",[15,43,40],{}," returns. The unit file hands it the null; the integration file hands it the real wrapper over a throwaway repository with a bare origin:",[46,47,52],"pre",{"className":48,"code":49,"language":50,"meta":51,"style":51},"language-ts shiki shiki-themes github-dark","\u002F\u002F tests\u002Finfrastructure\u002Fgit.test.ts\ngitContract((state) => Promise.resolve(GitWrapper.createNull(state)));\n\n\u002F\u002F tests\u002Finfrastructure\u002Fgit.integration.test.ts\ngitContract((state) => Promise.resolve(GitWrapper.create({ cwd: throwawayRepo(state).dir })));\n","ts","",[15,53,54,63,104,111,117],{"__ignoreMap":51},[55,56,59],"span",{"class":57,"line":58},"line",1,[55,60,62],{"class":61},"sAwPA","\u002F\u002F tests\u002Finfrastructure\u002Fgit.test.ts\n",[55,64,66,70,74,78,81,85,89,92,95,98,101],{"class":57,"line":65},2,[55,67,69],{"class":68},"svObZ","gitContract",[55,71,73],{"class":72},"s95oV","((",[55,75,77],{"class":76},"s9osk","state",[55,79,80],{"class":72},") ",[55,82,84],{"class":83},"snl16","=>",[55,86,88],{"class":87},"sDLfK"," Promise",[55,90,91],{"class":72},".",[55,93,94],{"class":68},"resolve",[55,96,97],{"class":72},"(GitWrapper.",[55,99,100],{"class":68},"createNull",[55,102,103],{"class":72},"(state)));\n",[55,105,107],{"class":57,"line":106},3,[55,108,110],{"emptyLinePlaceholder":109},true,"\n",[55,112,114],{"class":57,"line":113},4,[55,115,116],{"class":61},"\u002F\u002F tests\u002Finfrastructure\u002Fgit.integration.test.ts\n",[55,118,120,122,124,126,128,130,132,134,136,138,141,144,147],{"class":57,"line":119},5,[55,121,69],{"class":68},[55,123,73],{"class":72},[55,125,77],{"class":76},[55,127,80],{"class":72},[55,129,84],{"class":83},[55,131,88],{"class":87},[55,133,91],{"class":72},[55,135,94],{"class":68},[55,137,97],{"class":72},[55,139,140],{"class":68},"create",[55,142,143],{"class":72},"({ cwd: ",[55,145,146],{"class":68},"throwawayRepo",[55,148,149],{"class":72},"(state).dir })));\n",[11,151,152,154,155,157,158,161],{},[15,153,77],{}," is the repository the test wants: which branch is current, whether the tree is dirty, whether main is behind origin, which branches exist, what version origin's manifest carries. The null is handed the state; the real ",[15,156,40],{}," builds a repository in that state, with a tracked file so that ",[15,159,160],{},"commit --all"," has something to stage when the state says dirty.",[31,163,165],{"id":164},"what-went-red","What went red",[11,167,168,169,172],{},"Real git refuses to commit when there is nothing to commit. ",[15,170,171],{},"git commit --all"," on a clean tree exits 1. So the git contract got this item:",[46,174,176],{"className":48,"code":175,"language":50,"meta":51,"style":51},"it('refuses to commit a clean tree and records nothing', async () => {\n  const git = await make({});\n  const actions = git.trackActions();\n\n  await expect(git.commitAll('chore: nothing')).rejects.toThrow(\u002Fnothing to commit\u002F);\n\n  expect(actions.data).toEqual([]);\n});\n",[15,177,178,204,224,242,246,285,290,305],{"__ignoreMap":51},[55,179,180,183,186,190,193,196,199,201],{"class":57,"line":58},[55,181,182],{"class":68},"it",[55,184,185],{"class":72},"(",[55,187,189],{"class":188},"sU2Wk","'refuses to commit a clean tree and records nothing'",[55,191,192],{"class":72},", ",[55,194,195],{"class":83},"async",[55,197,198],{"class":72}," () ",[55,200,84],{"class":83},[55,202,203],{"class":72}," {\n",[55,205,206,209,212,215,218,221],{"class":57,"line":65},[55,207,208],{"class":83},"  const",[55,210,211],{"class":87}," git",[55,213,214],{"class":83}," =",[55,216,217],{"class":83}," await",[55,219,220],{"class":68}," make",[55,222,223],{"class":72},"({});\n",[55,225,226,228,231,233,236,239],{"class":57,"line":106},[55,227,208],{"class":83},[55,229,230],{"class":87}," actions",[55,232,214],{"class":83},[55,234,235],{"class":72}," git.",[55,237,238],{"class":68},"trackActions",[55,240,241],{"class":72},"();\n",[55,243,244],{"class":57,"line":113},[55,245,110],{"emptyLinePlaceholder":109},[55,247,248,251,254,257,260,262,265,268,271,273,276,280,282],{"class":57,"line":119},[55,249,250],{"class":83},"  await",[55,252,253],{"class":68}," expect",[55,255,256],{"class":72},"(git.",[55,258,259],{"class":68},"commitAll",[55,261,185],{"class":72},[55,263,264],{"class":188},"'chore: nothing'",[55,266,267],{"class":72},")).rejects.",[55,269,270],{"class":68},"toThrow",[55,272,185],{"class":72},[55,274,275],{"class":188},"\u002F",[55,277,279],{"class":278},"sns5M","nothing to commit",[55,281,275],{"class":188},[55,283,284],{"class":72},");\n",[55,286,288],{"class":57,"line":287},6,[55,289,110],{"emptyLinePlaceholder":109},[55,291,293,296,299,302],{"class":57,"line":292},7,[55,294,295],{"class":68},"  expect",[55,297,298],{"class":72},"(actions.data).",[55,300,301],{"class":68},"toEqual",[55,303,304],{"class":72},"([]);\n",[55,306,308],{"class":57,"line":307},8,[55,309,310],{"class":72},"});\n",[11,312,313],{},"The null, as it stood, answered every git command from a fixed map of outputs and never rejected anything. Run at the commit that introduced the contracts, the unit suite says so:",[46,315,320],{"className":316,"code":318,"language":319},[317],"language-text"," FAIL  tests\u002Finfrastructure\u002Fgit.test.ts > creates a branch, which is then the current one and exists, and records it\n FAIL  tests\u002Finfrastructure\u002Fgit.test.ts > commits everything on an edited tree, which is then clean, and records it\n FAIL  tests\u002Finfrastructure\u002Fgit.test.ts > refuses to commit a clean tree and records nothing\n FAIL  tests\u002Finfrastructure\u002Fnpm.test.ts > refuses a bump that is not a version, naming it, and records nothing\n      Tests  4 failed | 142 passed (146)\n","text",[15,321,318],{"__ignoreMap":51},[11,323,324],{},"The first two were plain drift, and the fix was to give the null a state it keeps: a created branch becomes the current one, a commit cleans the tree. The fourth was the npm null accepting anything as a version. The third is this post. I taught the null to refuse from its own state, the contract went green on the null and on a real repository, and nine other tests went red, all on the release rail: the command that ships a package.",[31,326,328],{"id":327},"two-wrappers-one-world","Two wrappers, one world",[11,330,331,332,335,336,339],{},"The rail begins by demanding a clean tree. Then ",[15,333,334],{},"cut"," creates a branch, asks npm to bump the version, writes the changelog, and commits everything. On a laptop that commit has plenty to commit: npm just rewrote ",[15,337,338],{},"package.json"," and the lock file, and the changelog has a new entry. Git sees those files because git and npm share a filesystem. Each one's writes are the other's reads.",[11,341,342],{},[343,344],"img",{"alt":345,"src":346},"On a laptop, the npm wrapper and the shell write into one tree that the git wrapper reads. In the rail's tests, the npm null records a bump on its own tracker and the git null reads its own state, and there is no tree between them.","\u002Fimages\u002Fblog\u002Fwhat-a-null-cannot-see-one-world.svg",[11,348,349,350,352],{},"In the rail's tests, the git null is created clean, as preflight requires. The npm null bumps nothing on any disk; it answers the version and records the bump on its tracker. The changelog is written by the shell, which the tests do not run. When ",[15,351,334],{}," reaches the commit, the git null looks at the only tree it has, its own state, finds it clean, and refuses. The real rail works. The test rail does not. The two nulls share nothing, because there is nothing for them to share.",[11,354,355],{},[343,356],{"alt":357,"src":358},"The rail step by step. The real tree goes clean, clean, dirty, dirty, clean, clean, dirtied by npm and the shell. The git null's own tree stays clean the whole way, so at commitAll a null that refuses a clean tree refuses the rail.","\u002Fimages\u002Fblog\u002Fwhat-a-null-cannot-see-the-rail.svg",[31,360,362],{"id":361},"the-rule","The rule",[11,364,365,366,369],{},"A null can model what passes through it. The same evening, the git null learned to keep a branch it had just created as the current one, because ",[15,367,368],{},"createBranch"," goes through the wrapper. Earlier the same day the observatory's database null learned to remember what it saves, for the same reason. Those were drifts, and the contract list caught them.",[11,371,372],{},"A file written by npm, or by the shell, does not pass through the git wrapper. The null has no filesystem on which that file could appear. So the rule for what belongs in a contract list is this: behaviour whose inputs the wrapper itself can observe. Behaviour that depends on the state of the world outside the wrapper belongs to the real side's narrow test, where the world is real.",[11,374,375],{},[343,376],{"alt":377,"src":378},"One item of behaviour: where does its input come from? Through this wrapper, its own reads and writes: the contract list, run on the null and on the real. From the world, a file another wrapper wrote, the registry, the clock: a narrow test on the real side only.","\u002Fimages\u002Fblog\u002Fwhat-a-null-cannot-see-the-rule.svg",[11,380,381],{},"The limit of a null is the limit of what it can observe.",[31,383,385],{"id":384},"three-ways-out","Three ways out",[387,388,389,405],"table",{},[390,391,392],"thead",{},[393,394,395,399,402],"tr",{},[396,397,398],"th",{},"Option",[396,400,401],{},"What it needs",[396,403,404],{},"What it costs",[406,407,408,420,431],"tbody",{},[393,409,410,414,417],{},[411,412,413],"td",{},"Configure the null dirty from the start",[411,415,416],{},"nothing new",[411,418,419],{},"preflight demands a clean tree first; one null cannot be clean at preflight and dirty at the commit",[393,421,422,425,428],{},[411,423,424],{},"Give the nulls a shared world",[411,426,427],{},"an in-memory filesystem that npm's bump dirties and git's status reads",[411,429,430],{},"a second filesystem to keep faithful across every wrapper that touches disk; the nulls become a simulation",[393,432,433,436,439],{},[411,434,435],{},"Make the null's commit permissive, keep the refusal on the real side, write down why",[411,437,438],{},"three lines fewer in the null, one test in the integration file",[411,440,441],{},"a test on nulls can no longer catch \"you committed before writing anything\"",[11,443,444],{},"We chose the third. The null's commit now reads, with its reason beside it:",[46,446,448],{"className":48,"code":447,"language":50,"meta":51,"style":51},"\u002F\u002F The null is a repository in a given state, and it keeps that state the way a repository\n\u002F\u002F would: a created branch becomes the current one and exists, a commit cleans the tree. It\n\u002F\u002F cannot see a file npm or the shell wrote, so it never refuses a commit for having nothing\n\u002F\u002F to commit; that is the real side's own test.\nif (command === 'commit') {\n  state.dirty = false;\n  return Promise.resolve('');\n}\n",[15,449,450,455,460,465,470,487,501,519],{"__ignoreMap":51},[55,451,452],{"class":57,"line":58},[55,453,454],{"class":61},"\u002F\u002F The null is a repository in a given state, and it keeps that state the way a repository\n",[55,456,457],{"class":57,"line":65},[55,458,459],{"class":61},"\u002F\u002F would: a created branch becomes the current one and exists, a commit cleans the tree. It\n",[55,461,462],{"class":57,"line":106},[55,463,464],{"class":61},"\u002F\u002F cannot see a file npm or the shell wrote, so it never refuses a commit for having nothing\n",[55,466,467],{"class":57,"line":113},[55,468,469],{"class":61},"\u002F\u002F to commit; that is the real side's own test.\n",[55,471,472,475,478,481,484],{"class":57,"line":119},[55,473,474],{"class":83},"if",[55,476,477],{"class":72}," (command ",[55,479,480],{"class":83},"===",[55,482,483],{"class":188}," 'commit'",[55,485,486],{"class":72},") {\n",[55,488,489,492,495,498],{"class":57,"line":287},[55,490,491],{"class":72},"  state.dirty ",[55,493,494],{"class":83},"=",[55,496,497],{"class":87}," false",[55,499,500],{"class":72},";\n",[55,502,503,506,508,510,512,514,517],{"class":57,"line":292},[55,504,505],{"class":83},"  return",[55,507,88],{"class":87},[55,509,91],{"class":72},[55,511,94],{"class":68},[55,513,185],{"class":72},[55,515,516],{"class":188},"''",[55,518,284],{"class":72},[55,520,521],{"class":57,"line":307},[55,522,523],{"class":72},"}\n",[11,525,526],{},"Shore's nulls are embedded stubs at the lowest level of each wrapper. A world they all share is a different design, and we did not want one for a single assertion.",[31,528,530],{"id":529},"where-the-lost-check-lives-now","Where the lost check lives now",[11,532,533],{},"The refusal did not vanish; it moved to where the world is real. The integration file keeps it, against a throwaway repository, and the contract carries a comment saying what the null cannot see:",[46,535,537],{"className":48,"code":536,"language":50,"meta":51,"style":51},"it('refuses to commit a clean tree, as git does', async () => {\n  const { dir } = throwawayRepo({});\n  const git = GitWrapper.create({ cwd: dir });\n  const actions = git.trackActions();\n\n  await expect(git.commitAll('chore: nothing')).rejects.toThrow(\u002Fgit commit\u002F);\n\n  expect(actions.data).toEqual([]);\n});\n",[15,538,539,558,578,594,608,612,641,645,655],{"__ignoreMap":51},[55,540,541,543,545,548,550,552,554,556],{"class":57,"line":58},[55,542,182],{"class":68},[55,544,185],{"class":72},[55,546,547],{"class":188},"'refuses to commit a clean tree, as git does'",[55,549,192],{"class":72},[55,551,195],{"class":83},[55,553,198],{"class":72},[55,555,84],{"class":83},[55,557,203],{"class":72},[55,559,560,562,565,568,571,573,576],{"class":57,"line":65},[55,561,208],{"class":83},[55,563,564],{"class":72}," { ",[55,566,567],{"class":87},"dir",[55,569,570],{"class":72}," } ",[55,572,494],{"class":83},[55,574,575],{"class":68}," throwawayRepo",[55,577,223],{"class":72},[55,579,580,582,584,586,589,591],{"class":57,"line":106},[55,581,208],{"class":83},[55,583,211],{"class":87},[55,585,214],{"class":83},[55,587,588],{"class":72}," GitWrapper.",[55,590,140],{"class":68},[55,592,593],{"class":72},"({ cwd: dir });\n",[55,595,596,598,600,602,604,606],{"class":57,"line":113},[55,597,208],{"class":83},[55,599,230],{"class":87},[55,601,214],{"class":83},[55,603,235],{"class":72},[55,605,238],{"class":68},[55,607,241],{"class":72},[55,609,610],{"class":57,"line":119},[55,611,110],{"emptyLinePlaceholder":109},[55,613,614,616,618,620,622,624,626,628,630,632,634,637,639],{"class":57,"line":287},[55,615,250],{"class":83},[55,617,253],{"class":68},[55,619,256],{"class":72},[55,621,259],{"class":68},[55,623,185],{"class":72},[55,625,264],{"class":188},[55,627,267],{"class":72},[55,629,270],{"class":68},[55,631,185],{"class":72},[55,633,275],{"class":188},[55,635,636],{"class":278},"git commit",[55,638,275],{"class":188},[55,640,284],{"class":72},[55,642,643],{"class":57,"line":292},[55,644,110],{"emptyLinePlaceholder":109},[55,646,647,649,651,653],{"class":57,"line":307},[55,648,295],{"class":68},[55,650,298],{"class":72},[55,652,301],{"class":68},[55,654,304],{"class":72},[55,656,658],{"class":57,"line":657},9,[55,659,310],{"class":72},[11,661,662,663,666,667,670],{},"And the CLI releases itself: every version of ",[15,664,665],{},"ekohacks"," ships by running ",[15,668,669],{},"ekohacks release",", so the rail runs for real against a real tree before anyone else depends on it. The suite proves the tool does what we said; the release proves what we said matches the world. That split was the point of the whole series on encoding the release, and this is one more thing that landed on the release's side of it.",[31,672,674],{"id":673},"replay-it","Replay it",[11,676,677,678,681],{},"The red commit and the green commit are both on ",[15,679,680],{},"main",". The moment in between, the rail going red, lived in a working tree and was never committed, so the last step recreates it with a three-line change.",[46,683,687],{"className":684,"code":685,"language":686,"meta":51,"style":51},"language-sh shiki shiki-themes github-dark","git clone git@github.com:ekohacks\u002Fcli.git && cd cli\nnpm ci\n\ngit checkout 39943ba        # the contracts, before the nulls were fixed\nnpx vitest run              # 4 failed | 142 passed, the four lines quoted above\n\ngit checkout fece0c2        # the nulls fixed, the refusal moved to the real side\nnpx vitest run              # 145 passed\nnpm run test:integration    # the real side: throwaway repositories, a real shell, the registry\n","sh",[15,688,689,709,717,721,734,748,752,764,775],{"__ignoreMap":51},[55,690,691,694,697,700,703,706],{"class":57,"line":58},[55,692,693],{"class":68},"git",[55,695,696],{"class":188}," clone",[55,698,699],{"class":188}," git@github.com:ekohacks\u002Fcli.git",[55,701,702],{"class":72}," && ",[55,704,705],{"class":87},"cd",[55,707,708],{"class":188}," cli\n",[55,710,711,714],{"class":57,"line":65},[55,712,713],{"class":68},"npm",[55,715,716],{"class":188}," ci\n",[55,718,719],{"class":57,"line":106},[55,720,110],{"emptyLinePlaceholder":109},[55,722,723,725,728,731],{"class":57,"line":113},[55,724,693],{"class":68},[55,726,727],{"class":188}," checkout",[55,729,730],{"class":188}," 39943ba",[55,732,733],{"class":61},"        # the contracts, before the nulls were fixed\n",[55,735,736,739,742,745],{"class":57,"line":119},[55,737,738],{"class":68},"npx",[55,740,741],{"class":188}," vitest",[55,743,744],{"class":188}," run",[55,746,747],{"class":61},"              # 4 failed | 142 passed, the four lines quoted above\n",[55,749,750],{"class":57,"line":287},[55,751,110],{"emptyLinePlaceholder":109},[55,753,754,756,758,761],{"class":57,"line":292},[55,755,693],{"class":68},[55,757,727],{"class":188},[55,759,760],{"class":188}," fece0c2",[55,762,763],{"class":61},"        # the nulls fixed, the refusal moved to the real side\n",[55,765,766,768,770,772],{"class":57,"line":307},[55,767,738],{"class":68},[55,769,741],{"class":188},[55,771,744],{"class":188},[55,773,774],{"class":61},"              # 145 passed\n",[55,776,777,779,781,784],{"class":57,"line":657},[55,778,713],{"class":68},[55,780,744],{"class":188},[55,782,783],{"class":188}," test:integration",[55,785,786],{"class":61},"    # the real side: throwaway repositories, a real shell, the registry\n",[11,788,789,790,793,794,796],{},"To watch the rail break, give the null back its refusal. In ",[15,791,792],{},"src\u002Finfrastructure\u002Fgit.ts",", inside ",[15,795,100],{},":",[46,798,802],{"className":799,"code":800,"language":801,"meta":51,"style":51},"language-diff shiki shiki-themes github-dark","       if (command === 'commit') {\n+        if (!state.dirty) {\n+          return Promise.reject(new Error('nothing to commit, working tree clean'));\n+        }\n         state.dirty = false;\n         return Promise.resolve('');\n       }\n","diff",[15,803,804,809,814,819,824,829,834],{"__ignoreMap":51},[55,805,806],{"class":57,"line":58},[55,807,808],{},"       if (command === 'commit') {\n",[55,810,811],{"class":57,"line":65},[55,812,813],{},"+        if (!state.dirty) {\n",[55,815,816],{"class":57,"line":106},[55,817,818],{},"+          return Promise.reject(new Error('nothing to commit, working tree clean'));\n",[55,820,821],{"class":57,"line":113},[55,822,823],{},"+        }\n",[55,825,826],{"class":57,"line":119},[55,827,828],{},"         state.dirty = false;\n",[55,830,831],{"class":57,"line":287},[55,832,833],{},"         return Promise.resolve('');\n",[55,835,836],{"class":57,"line":292},[55,837,838],{},"       }\n",[11,840,841,842,845,846,849,850,853,854,857,858,861],{},"Then ",[15,843,844],{},"npx vitest run tests\u002Flogic",": nine failures, four in ",[15,847,848],{},"cut.test.ts",", four in ",[15,851,852],{},"release.test.ts",", and one in ",[15,855,856],{},"docs.test.ts",", because ",[15,859,860],{},"docs draft"," also commits files the shell wrote. Not one of those tests mentions git's refusal. They all go red because the null now knows something that is not true in any of them.",[31,863,865],{"id":864},"what-this-says-about-contract-lists","What this says about contract lists",[11,867,868],{},"Contract lists find drift. Eleven drifts across the observatory and EkoLite on the morning we wrote the first ones, four more in the CLI that night. They also find the edges of the pattern, which no amount of drift-hunting would show you: the exact point where a null stops being able to stand in for the real thing, and why. When a contract item will not go green on the null, ask first what the null would have to see to honour it. If the answer is \"something another wrapper wrote\", the item is not a contract. It is a narrow test, and it was always going to be.",[11,870,871,872,874,875,878,879,882],{},"The receipts are two commits in ",[15,873,28],{},": ",[15,876,877],{},"39943ba",", the contracts, red, and ",[15,880,881],{},"fece0c2",", the nulls fixed, green, both from 2 November 2026.",[884,885,886],"style",{},"html pre.shiki code .sAwPA, html code.shiki .sAwPA{--shiki-default:#6A737D}html pre.shiki code .svObZ, html code.shiki .svObZ{--shiki-default:#B392F0}html pre.shiki code .s95oV, html code.shiki .s95oV{--shiki-default:#E1E4E8}html pre.shiki code .s9osk, html code.shiki .s9osk{--shiki-default:#FFAB70}html pre.shiki code .snl16, html code.shiki .snl16{--shiki-default:#F97583}html pre.shiki code .sDLfK, html code.shiki .sDLfK{--shiki-default:#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 pre.shiki code .sU2Wk, html code.shiki .sU2Wk{--shiki-default:#9ECBFF}html pre.shiki code .sns5M, html code.shiki .sns5M{--shiki-default:#DBEDFF}",{"title":51,"searchDepth":65,"depth":65,"links":888},[889,890,891,892,893,894,895,896],{"id":33,"depth":65,"text":34},{"id":164,"depth":65,"text":165},{"id":327,"depth":65,"text":328},{"id":361,"depth":65,"text":362},{"id":384,"depth":65,"text":385},{"id":529,"depth":65,"text":530},{"id":673,"depth":65,"text":674},{"id":864,"depth":65,"text":865},"Testing and TDD","2026-11-02T23:00:00.000Z","A contract item that would not go green on a null taught us the rule: a null can model what passes through it, and a file another wrapper wrote does not.",false,"md","\u002Fimages\u002Fblog\u002Fwhat-a-null-cannot-see-hero.jpg",{},"\u002Fblog\u002Fwhat-a-null-cannot-see","The EkoHacks CLI",{"title":5,"description":899},null,"blog\u002Fwhat-a-null-cannot-see",[910,911,912,913],"nullables","testing","contract-tests","cli","0Oi4Mn8FjJ7l3PsMQd59Z8eSFcaV_jCaGBmfCU5u20E",[916,927,939,946,955,961,970,977,984,993,1000,1011,1020,1027,1034,1045,1054,1061,1068,1077,1085,1093,1103,1110,1118,1125,1131,1137,1146,1154,1164,1172,1179,1189,1197,1203,1213,1222,1230,1238,1246,1254,1261,1272,1280,1288,1295,1301,1308,1317,1325,1332,1339,1348,1354,1362,1368,1376,1382,1391,1397,1404,1411,1413,1421,1427,1433,1439],{"path":917,"title":918,"description":919,"date":920,"author":6,"image":921,"readingMinutes":287,"category":922,"project":923,"tags":924,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fa-branch-is-inventory","A Branch Is Inventory","Unintegrated code is stock in a warehouse, and it depreciates. Our branch was one merge behind the trunk and that was enough for a conflict. What waiting costs.","2026-07-09T09:38:00.000Z","\u002Fimages\u002Fblog\u002Fa-branch-is-inventory-hero.jpg","Craft and Practice","The Dojo",[693,925,926],"continuous-integration","craft",{"path":928,"title":929,"description":930,"date":931,"author":6,"image":907,"readingMinutes":292,"category":932,"project":933,"tags":934,"draft":900,"series":938,"seriesOrder":106},"\u002Fblog\u002Fa-map-drawn-by-the-build","A Map Drawn by the Build","Thirty six of our pages had never been visited by Google because nothing said they existed. The sitemap is drawn from the build, and it found a draft.","2026-08-22T20:30:00.000Z","Shipping and Operations","ekohacks.com",[935,936,937],"seo","operations","nuxt","From Invisible to Findable",{"path":940,"title":941,"description":942,"date":943,"author":6,"image":907,"readingMinutes":287,"category":932,"project":933,"tags":944,"draft":900,"series":938,"seriesOrder":657},"\u002Fblog\u002Fa-privacy-policy-you-can-diff","A Privacy Policy You Can Diff","Our legal pages are files in the same repository as the site. Each now lists every change to its text, with the date and the diff, drawn from git at build time.","2026-08-23T16:00:00.000Z",[945,936,937],"privacy",{"path":947,"title":948,"description":949,"date":950,"author":6,"image":951,"readingMinutes":292,"category":897,"project":952,"tags":953,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fan-off-switch-not-a-disguise","An Off Switch, Not a Disguise","A Nullable differs from a stub in whether production code can tell the fake is there. Finding the word fixed a socket wrapper that had been lying to its tests.","2026-07-20T09:05:00.000Z","\u002Fimages\u002Fblog\u002Fan-off-switch-not-a-disguise-hero.jpg","EkoLite",[910,911,954],"websocket",{"path":956,"title":957,"description":958,"date":959,"author":6,"image":960,"readingMinutes":113,"category":922,"project":907,"tags":907,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fbuilding-engineering-teams-that-learn-together","Building Engineering Teams That Learn Together","The strongest engineering teams are not the ones with the most talented individuals. They are the ones that have learned how to learn together.","2026-02-05T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fteams-that-learn-together-hero.jpg",{"path":962,"title":963,"description":964,"date":965,"author":6,"image":966,"readingMinutes":113,"category":932,"project":923,"tags":967,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fderived-state-recompute","The Fix Was Merged, but the Numbers Were Still Wrong","We fixed a scoring bug, reviewed it, merged it, and production still showed the old numbers. Derived data is a cache that goes stale, and it must be recomputed.","2026-07-08T21:43:55.000Z","\u002Fimages\u002Fblog\u002Fderived-state-recompute-hero.jpg",[936,968,969],"deployment","idempotency",{"path":971,"title":972,"description":973,"date":974,"author":975,"image":976,"readingMinutes":287,"category":932,"project":907,"tags":907,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fdockerising-nestjs-nuxt-monorepo-with-pnpm","Dockerising a NestJS and Nuxt Monorepo with pnpm","Dockerising a NestJS and Nuxt monorepo with pnpm: separate multi stage Dockerfiles for development and production, one Compose file, reproducible environments.","2026-02-09T09:00:00.000Z","Ogochukwu Okpala","\u002Fimages\u002Fblog\u002Fdocker-monorepo-hero.png",{"path":978,"title":979,"description":980,"date":981,"author":6,"image":907,"readingMinutes":292,"category":932,"project":933,"tags":982,"draft":900,"series":938,"seriesOrder":983},"\u002Fblog\u002Ffifty-posts-and-an-empty-robots-txt","Fifty Posts and an Empty robots.txt","Our site scored 100 for SEO and nobody could find it. The audit that started a series on making a site findable, and on what measuring visitors costs them.","2026-08-21T14:30:00.000Z",[935,936,926],0,{"path":985,"title":986,"description":987,"date":988,"author":6,"image":989,"readingMinutes":113,"category":922,"project":923,"tags":990,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Ffrom-convention-to-plugin","From Convention to Plugin","A five step procedure for webhook signature checks, easy to forget and silent when you did, became one Fastify plugin. The safe path is now impossible to skip.","2026-04-17T08:26:12.000Z","\u002Fimages\u002Fblog\u002Ffrom-convention-to-plugin-hero.jpg",[926,991,992],"fastify","security",{"path":994,"title":995,"description":996,"date":997,"author":6,"image":998,"readingMinutes":119,"category":932,"project":923,"tags":999,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fintegrate-against-the-trunk","Integrate Against the Trunk, Not Your Copy of It","A rebase onto a stale local main succeeds, looks resolved, and leaves the pull request conflicting. Everything on your machine is a photograph of the trunk.","2026-07-09T09:22:00.000Z","\u002Fimages\u002Fblog\u002Fintegrate-against-the-trunk-hero.jpg",[693,925,936],{"path":1001,"title":1002,"description":1003,"date":1004,"author":1005,"image":907,"readingMinutes":307,"category":1006,"project":907,"tags":1007,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Finversion-of-control-does-not-need-a-di-container","Inversion of Control Doesn't Need a DI Container","We started designing a dependency scanner, and its needs led us to Inversion of Control. It turned out we were already doing it, without a DI container.","2026-10-05T09:00:00.000Z","Anastasios Orfanidis","Design and Architecture",[1008,1009,911,910,1010],"architecture","inversion-of-control","leadership",{"path":1012,"title":1013,"description":1014,"date":1015,"author":6,"image":1016,"readingMinutes":113,"category":922,"project":923,"tags":1017,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Flandmines","Landmines","Most CLAUDE.md files grow because documenting a convention feels like progress. Ours holds landmines only: facts the code cannot tell you, costly to step on.","2026-04-17T10:15:06.000Z","\u002Fimages\u002Fblog\u002Flandmines-hero.jpg",[926,1018,1019],"documentation","ai-agents",{"path":1021,"title":1022,"description":1023,"date":1024,"author":6,"image":1025,"readingMinutes":287,"category":932,"project":923,"tags":1026,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fnever-break-the-build","Never Break the Build","If the software worked five minutes ago, only five minutes of change can be to blame. What a green trunk buys, who pays for it, and a check that could not fail.","2026-07-09T09:54:00.000Z","\u002Fimages\u002Fblog\u002Fnever-break-the-build-hero.jpg",[925,936,911],{"path":1028,"title":1029,"description":1030,"date":1031,"author":6,"image":907,"readingMinutes":292,"category":932,"project":933,"tags":1032,"draft":900,"series":938,"seriesOrder":65},"\u002Fblog\u002Fone-address-per-page","One Address per Page","Every page on our site answered with a redirect before it answered with a page. How we made the build emit one address, declare it, and check it live.","2026-08-22T18:30:00.000Z",[935,936,937,1033],"netlify",{"path":1035,"title":1036,"description":1037,"date":1038,"author":6,"image":1039,"readingMinutes":113,"category":1006,"project":905,"tags":1040,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fone-command-three-policies","One Command, Three Policies, Zero Duplication","Preflight, cut and ship each worked alone before one command composed them. Why the composition layer is forty lines, and how the scrollback became the record.","2026-07-21T17:20:00.000Z","\u002Fimages\u002Fblog\u002Fone-command-three-policies-hero.jpg",[1041,1042,1043,1044],"composition","cli-design","narration","encoding-the-release",{"path":1046,"title":1047,"description":1048,"date":1049,"author":6,"image":1050,"readingMinutes":292,"category":1006,"project":952,"tags":1051,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fone-way-to-fail","One Way to Fail","Closing the convergence so an upload fails in the same error shape as every other call, mostly by deleting the code that made the two shapes differ.","2026-07-20T10:05:00.000Z","\u002Fimages\u002Fblog\u002Fone-way-to-fail-hero.jpg",[1052,910,1053],"design","error handling",{"path":1055,"title":1056,"description":1057,"date":1058,"author":6,"image":1059,"readingMinutes":287,"category":932,"project":923,"tags":1060,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fready-to-ship-not-ready-to-release","Ready to Ship, Not Ready to Release","Most teams hide a delay between saying done and being able to ship. Being ready to release at any moment, and why our deploy ships the commit that was tested.","2026-07-09T10:02:00.000Z","\u002Fimages\u002Fblog\u002Fready-to-ship-hero.jpg",[925,968,936],{"path":1062,"title":1063,"description":1064,"date":1065,"author":6,"image":1066,"readingMinutes":287,"category":922,"project":923,"tags":1067,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Frebase-or-merge-is-the-wrong-argument","Rebase or Merge Is the Wrong Argument","Both commands reconcile the same commits and hit the same conflicts. What differs is when you resolve them. The loud argument hides a quiet one: long branches.","2026-07-09T09:30:00.000Z","\u002Fimages\u002Fblog\u002Frebase-or-merge-hero.jpg",[693,925,926],{"path":1069,"title":1070,"description":1071,"date":1072,"author":6,"image":1073,"readingMinutes":106,"category":1006,"project":923,"tags":1074,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Freconstructing-the-past","Reconstructing the Past","Connect a repository with a year of history and a trend curve reaches back months. How a system shows a past it never saw, recomputed from one source of truth.","2026-07-07T21:26:35.000Z","\u002Fimages\u002Fblog\u002Freconstructing-the-past-hero.jpg",[1052,1075,1076],"data","onboarding",{"path":1078,"title":1079,"description":1080,"date":1081,"author":6,"image":907,"readingMinutes":287,"category":897,"project":905,"tags":1082,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fretiring-a-board","Retiring a Board: User Testing a CLI Before You Ship It","An afternoon of user testing against a real Linear board, and the four findings a green test suite could never have written.","2026-08-01T10:20:00.000Z",[1083,910,1042,1084],"user-testing","linear",{"path":1086,"title":1087,"description":1088,"date":1089,"author":6,"image":1090,"readingMinutes":119,"category":897,"project":923,"tags":1091,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fretrofitting-testing-without-mocks","Retrofitting Testing Without Mocks","The server had 37 vi.mock calls and no test that touched the database. Five steps to remove every mock with Nullables: real logic, real data, sociable tests.","2026-04-17T12:30:37.000Z","\u002Fimages\u002Fblog\u002Fretrofitting-without-mocks-hero.jpg",[911,910,1092],"mocks",{"path":1094,"title":1095,"description":1096,"date":1097,"author":6,"image":1098,"readingMinutes":113,"category":932,"project":905,"tags":1099,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Frun-it-by-hand-until-it-is-boring","Run It by Hand Until It Is Boring","Releasing EkoLite was a document before it was a command, and stayed one until running it by hand had nothing left to teach us. Why we automate operations last.","2026-07-21T17:00:00.000Z","\u002Fimages\u002Fblog\u002Frun-it-by-hand-hero.jpg",[1100,1101,1102,1044],"automation","releasing","process",{"path":1104,"title":1105,"description":1106,"date":1072,"author":6,"image":1107,"readingMinutes":106,"category":932,"project":923,"tags":1108,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fsafe-to-run-twice","Safe to Run Twice","Connect the same repository twice, or let a webhook redeliver an event. Idempotency in two halves: writes through a natural key, side effects behind a ledger.","\u002Fimages\u002Fblog\u002Fsafe-to-run-twice-hero.jpg",[936,969,1109],"webhooks",{"path":1111,"title":1112,"description":1113,"date":1114,"author":6,"image":907,"readingMinutes":287,"category":932,"project":923,"tags":1115,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fsource-or-execute","Source or Execute","Our dojo laptops are provisioned by one bash script of nearly twelve hundred lines. Splitting it into modules is two decisions, not one, and we chose a hybrid.","2026-08-01T09:00:00.000Z",[1116,1117,1041,936],"shell","provisioning",{"path":1119,"title":1120,"description":1121,"date":907,"author":1122,"image":1123,"readingMinutes":58,"category":1006,"project":1124,"tags":907,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fstage-then-transform-why-your-crawler-should-never-touch-production-tables","Stage, Then Transform: Crawlers Stay Out of Production","Where data from an external source lands first decides whether the pipeline still works in six months. The staging pattern, shown with a crawler we build now.","EkoHacks","\u002Fimages\u002Fblog\u002Fstage-then-transform-hero.jpg","Propi",{"path":1126,"title":1127,"description":1128,"date":1129,"author":6,"image":907,"readingMinutes":292,"category":897,"project":933,"tags":1130,"draft":900,"series":938,"seriesOrder":119},"\u002Fblog\u002Ftelling-the-machine-what-the-page-is","Telling the Machine What the Page Is","Structured data on every post, and a test that runs after each build and fails it when a page lacks what a crawler needs. First run: forty nine problems.","2026-08-22T23:30:00.000Z",[935,911,937],{"path":1132,"title":1133,"description":1134,"date":1135,"author":6,"image":1136,"readingMinutes":65,"category":897,"project":907,"tags":907,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Ftest-driven-development-is-not-about-tests","Test Driven Development Is Not About Tests","TDD is widely misunderstood. It is not a testing strategy. It is a design tool that produces simpler, more focused code by making you think before you type.","2026-02-01T09:00:00.000Z","\u002Fimages\u002Fblog\u002Ftdd-not-about-tests-hero.jpg",{"path":1138,"title":1139,"description":1140,"date":1141,"author":6,"image":1142,"readingMinutes":119,"category":897,"project":905,"tags":1143,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Ftesting-a-release-without-releasing","Testing a Release Without Releasing","How to test watch CI until green, then merge, hundreds of times a day with no CI in sight. Nullables, output trackers, and a fake that copies GitHub shapes.","2026-07-21T17:10:00.000Z","\u002Fimages\u002Fblog\u002Ftesting-a-release-without-releasing-hero.jpg",[910,1144,1145,1044],"testing-without-mocks","james-shore",{"path":1147,"title":1148,"description":1149,"date":1150,"author":6,"image":907,"readingMinutes":287,"category":897,"project":923,"tags":1151,"draft":900,"series":1153,"seriesOrder":58},"\u002Fblog\u002Fthe-best-coverage-number-in-the-room","The Best Coverage Number in the Room","Same commit, same spec, same test count. The mocked suite ran 5.6 times faster, covered 3.5 fewer points of real code, and posted the best branch coverage.","2026-08-27T12:00:00.000Z",[911,910,1092,1152],"coverage","What Mocks Cost",{"path":1155,"title":1156,"description":1157,"date":1158,"author":6,"image":1159,"readingMinutes":119,"category":1006,"project":952,"tags":1160,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-bug-that-resolved-successfully","The Bug That Resolved Successfully","A bug that threw no error and dropped every document because the client guessed a collection from a name. The fix was one honest field on the ready message.","2026-07-05T10:16:16.000Z","\u002Fimages\u002Fblog\u002Fbug-resolved-successfully-hero.jpg",[1161,1162,911,1163],"protocol","pubsub","tdd",{"path":1165,"title":1166,"description":1167,"date":1168,"author":6,"image":1169,"readingMinutes":287,"category":897,"project":952,"tags":1170,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-clock-that-learned-to-walk","The Clock That Learned to Walk","A stub clock that teleported to the end of the window instead of passing through time, so a timer set inside another never fired. A real clock model fixed it.","2026-07-07T22:44:42.000Z","\u002Fimages\u002Fblog\u002Fthe-clock-that-learned-to-walk-hero.jpg",[910,911,1171],"refactoring",{"path":1173,"title":1174,"description":1175,"date":1176,"author":6,"image":1177,"readingMinutes":119,"category":922,"project":923,"tags":1178,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-conflict-nobody-wrote","The Conflict Nobody Wrote","A pull request conflicted in a file neither author had typed into. A generated document recorded test line numbers, both branches shifted them, git merges text.","2026-07-09T09:14:00.000Z","\u002Fimages\u002Fblog\u002Fthe-conflict-nobody-wrote-hero.jpg",[693,925,926],{"path":1180,"title":1181,"description":1182,"date":1183,"author":6,"image":1184,"readingMinutes":307,"category":932,"project":923,"tags":1185,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-container-that-wasnt-there","The Container That Wasn't There","Production served HTML where the browser asked for JavaScript, and nothing in our code had changed. A week debugging a platform we cannot see inside.","2026-07-17T14:30:00.000Z","\u002Fimages\u002Fblog\u002Fcontainer-that-wasnt-there-hero.jpg",[936,1186,1187,1188],"debugging","european-tech","community",{"path":1190,"title":1191,"description":1192,"date":1193,"author":6,"image":1194,"readingMinutes":287,"category":1006,"project":952,"tags":1195,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-design-we-never-drew","The Design We Never Drew","How the upload path in EkoLite arrived rather than being drawn, and the two habits, test first and integrate always, that turn an emerging shape into a design.","2026-07-20T10:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-design-we-never-drew-hero.jpg",[1052,1163,1196],"continuous integration",{"path":1198,"title":1199,"description":1200,"date":1201,"author":6,"image":1202,"readingMinutes":113,"category":922,"project":907,"tags":907,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-dojo-way-deliberate-practice-for-software-developers","The Dojo Way: Deliberate Practice for Software Developers","Most developers learn on the job, and the job rarely teaches the fundamentals. The Dojo borrows from martial arts to make a space for deliberate practice.","2026-01-22T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-dojo-way-hero.jpg",{"path":1204,"title":1205,"description":1206,"date":1207,"author":6,"image":1208,"readingMinutes":292,"category":922,"project":923,"tags":1209,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-done-column-is-a-syllabus","The Done Column Is a Syllabus","We asked Linear for every story our team has ever finished, all 187 of them, and read the column end to end. It turned out to be the course we teach.","2026-07-31T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fdone-column-syllabus-hero.jpg",[1210,1211,1212,1084],"graphql","stories","xp",{"path":1214,"title":1215,"description":1216,"date":1217,"author":6,"image":1218,"readingMinutes":119,"category":897,"project":905,"tags":1219,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-failing-test-is-the-review","The Failing Test Is the Review","Every behaviour in the CLI landed as two commits: a red test, then the code that makes it green. Reviewing intent before implementation, and why red never bent.","2026-07-21T17:05:00.000Z","\u002Fimages\u002Fblog\u002Fthe-failing-test-is-the-review-hero.jpg",[1163,1220,1221,1044],"red-first","code-review",{"path":1223,"title":1224,"description":1225,"date":1226,"author":6,"image":1227,"readingMinutes":119,"category":932,"project":905,"tags":1228,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-first-real-run-said-no","The First Real Run Said No","The tool's first real outing ended with FAIL and a stopped rail, and we count it as a success. A naive sounding question then uncovered a quiet failure mode.","2026-07-21T17:25:00.000Z","\u002Fimages\u002Fblog\u002Fthe-first-real-run-said-no-hero.jpg",[1101,1229,693,1044],"preflight",{"path":1231,"title":1232,"description":1233,"date":1234,"author":6,"image":1235,"readingMinutes":119,"category":932,"project":952,"tags":1236,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-five-second-goodbye","The Five Second Goodbye","A shutdown that arms its own deadline so a hung close cannot strand the process, with the policy lifted out of the boot shell so every decision can be tested.","2026-07-05T12:03:00.000Z","\u002Fimages\u002Fblog\u002Fthe-five-second-goodbye-hero.jpg",[1237,910,911],"shutdown",{"path":1239,"title":1240,"description":1241,"date":1242,"author":6,"image":1243,"readingMinutes":287,"category":932,"project":952,"tags":1244,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-framework-in-the-description-field","The Framework in the Description Field","Our package called itself a framework, but it could not be installed. The fix was to stop trusting the build and become the stranger who installs the tarball.","2026-07-08T17:28:39.000Z","\u002Fimages\u002Fblog\u002Fframework-description-field-hero.jpg",[1245,911,926],"packaging",{"path":1247,"title":1248,"description":1249,"date":1250,"author":6,"image":1251,"readingMinutes":113,"category":1006,"project":905,"tags":1252,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-gate-always-asks","The Gate Always Asks","A release automates things you cannot take back, so which of them may a machine do without asking a human? Pauses, and a yes flag that opens no door.","2026-07-21T17:15:00.000Z","\u002Fimages\u002Fblog\u002Fthe-gate-always-asks-hero.jpg",[1100,1253,1052,1044],"human-in-the-loop",{"path":1255,"title":1256,"description":1257,"date":1258,"author":6,"image":1259,"readingMinutes":287,"category":932,"project":923,"tags":1260,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-gate-went-red-and-it-was-right","The Gate Went Red, and It Was Right","We added the missing check on our API specification and it failed on its first run, because the file was never a function of the source. A clean machine saw it.","2026-07-09T10:10:00.000Z","\u002Fimages\u002Fblog\u002Fgate-went-red-hero.jpg",[925,936,926],{"path":1262,"title":1263,"description":1264,"date":1265,"author":6,"image":1266,"readingMinutes":292,"category":897,"project":1267,"tags":1268,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-inbox-that-delivers-nowhere","The Inbox That Delivers Nowhere","Signing in by emailed code puts a mailbox on the critical path. How we use Mailpit on Studomia, how our tests read the code, and what one question taught us.","2026-09-28T09:00:00.000Z","\u002Fimages\u002Fblog\u002Finbox-that-delivers-nowhere-hero.jpg","Studomia",[1269,1270,1271,911],"mailpit","local-development","passwordless",{"path":1273,"title":1274,"description":1275,"date":1276,"author":6,"image":1277,"readingMinutes":287,"category":932,"project":952,"tags":1278,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-knock-and-the-axe","The Knock and the Axe","SIGTERM is a request your process can hear and SIGKILL is removal it never sees, and the fixed window between them is exactly where graceful shutdown lives.","2026-07-20T12:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-knock-and-the-axe-hero.jpg",[1237,936,1279],"signals",{"path":1281,"title":1282,"description":1283,"date":1284,"author":6,"image":1285,"readingMinutes":307,"category":922,"project":952,"tags":1286,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-line-nothing-could-see","The Line Nothing Could See","Reviewing the reasoning under a refactor, not only the diff. Ask of every line what can observe it, and back each review claim with a test the author can run.","2026-07-07T22:43:00.000Z","\u002Fimages\u002Fblog\u002Fthe-line-nothing-could-see-hero.jpg",[1221,1171,1287,911],"teaching",{"path":1289,"title":1290,"description":1291,"date":1292,"author":6,"image":1293,"readingMinutes":287,"category":1006,"project":952,"tags":1294,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-long-way-round","The Long Way Round","The count a method computes reaches the screen not through its return value but by being written into a collection a client subscribes to, EkoLite's channel.","2026-07-20T11:05:00.000Z","\u002Fimages\u002Fblog\u002Fthe-long-way-round-hero.jpg",[1052,1162,910],{"path":1296,"title":1297,"description":1298,"date":1299,"author":6,"image":907,"readingMinutes":292,"category":932,"project":933,"tags":1300,"draft":900,"series":938,"seriesOrder":58},"\u002Fblog\u002Fthe-page-that-existed-only-when-someone-linked-to-it","The Page That Existed Only When Someone Linked to It","Our blog vanished in production while passing every local check. How we found the cause, fixed it twice, and how the fix gave every page a twin.","2026-08-22T16:00:00.000Z",[935,936,937],{"path":1302,"title":1303,"description":1304,"date":1305,"author":6,"image":1306,"readingMinutes":307,"category":932,"project":923,"tags":1307,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-previews-that-served-production","The Previews That Served Production","Two weeks after the container that was not there, the same impossible error came back. The orphans were our own pull request previews. One diagnosis was wrong.","2026-07-31T17:00:00.000Z","\u002Fimages\u002Fblog\u002Fpreviews-served-production-hero.jpg",[936,1186,1187,1188],{"path":1309,"title":1310,"description":1311,"date":1312,"author":6,"image":1313,"readingMinutes":113,"category":922,"project":905,"tags":1314,"draft":109,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-release-is-the-acceptance-test","The Release Is the Acceptance Test","Not a green suite, not a good demo: a real version of a real package, live on npm, shipped by the tool. What red first tests proved, and what only reality did.","2026-07-21T17:35:00.000Z","\u002Fimages\u002Fblog\u002Frelease-acceptance-test-hero.jpg",[1101,1315,1316,1044],"dogfooding","acceptance-testing",{"path":1318,"title":1319,"description":1320,"date":1321,"author":6,"image":1322,"readingMinutes":292,"category":897,"project":952,"tags":1323,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-script-that-never-ran","The Script That Never Ran","Joining two abilities EkoLite already had, a method that runs and a runner that shells out, and proving the join with a nulled runner before any script exists.","2026-07-20T11:00:00.000Z","\u002Fimages\u002Fblog\u002Fthe-script-that-never-ran-hero.jpg",[911,910,1324],"scriptrunner",{"path":1326,"title":1327,"description":1328,"date":1329,"author":6,"image":907,"readingMinutes":307,"category":932,"project":933,"tags":1330,"draft":900,"series":938,"seriesOrder":287},"\u002Fblog\u002Fthe-small-things-the-crawler-notices","The Small Things the Crawler Notices","Speed is the part of findability you can measure on a laptop. Fonts and images we shipped without looking, an error page we did not have, and the numbers after.","2026-08-23T10:00:00.000Z",[935,936,1331],"performance",{"path":1333,"title":1334,"description":1335,"date":1336,"author":6,"image":1337,"readingMinutes":292,"category":897,"project":952,"tags":1338,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-test-that-could-not-fail","The Test That Could Not Fail","A green test that stayed green when we deleted the behaviour it was named after, and the single blunt question that exposed it.","2026-07-20T09:00:00.000Z","\u002Fimages\u002Fblog\u002Ftest-that-could-not-fail-hero.jpg",[911,910,954],{"path":1340,"title":1341,"description":1342,"date":1343,"author":1122,"image":1344,"readingMinutes":287,"category":922,"project":1345,"tags":1346,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-website-that-told-the-same-story-twice","The Website That Told the Same Story Twice","Our home and story pages felt like one story across two screens. Rather than argue by taste, we modelled our words as data and asked a database one question.","2026-06-06","\u002Fimages\u002Fblog\u002Fwebsite-same-story-twice-hero.jpg","The Website",[926,1347,1075,1287],"content",{"path":1349,"title":1350,"description":1351,"date":1158,"author":6,"image":1352,"readingMinutes":119,"category":897,"project":952,"tags":1353,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-words-we-work-in","The Words We Work In","The vocabulary we test with, Nullable, embedded stub, stub object, output tracking, each grounded in real code so a review points at a line, never at taste.","\u002Fimages\u002Fblog\u002Fthe-words-we-work-in-hero.jpg",[910,911,926,1287],{"path":1355,"title":1356,"description":1357,"date":1343,"author":1122,"image":1358,"readingMinutes":113,"category":922,"project":1345,"tags":1359,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fthe-workflow-is-the-lesson","The Workflow Is the Lesson","We are moving our content out of a cloud CMS and into git. Why a school that teaches a keyboard first way of working should practise it in the open, and how.","\u002Fimages\u002Fblog\u002Fworkflow-lesson-hero.png",[1360,693,1287,1361],"workflow","accessibility",{"path":1363,"title":1364,"description":1365,"date":1366,"author":6,"image":907,"readingMinutes":287,"category":897,"project":923,"tags":1367,"draft":900,"series":1153,"seriesOrder":65},"\u002Fblog\u002Ftwenty-six-more-tests-four-fewer-behaviours","Twenty Six More Tests, Four Fewer Behaviours","Removing the mocks grew the suite from 44 tests to 70 and quietly deleted four behaviours, every one of them a failure path. Test count is not coverage.","2026-08-28T09:00:00.000Z",[911,910,1092,1152],{"path":1369,"title":1370,"description":1371,"date":1372,"author":6,"image":1373,"readingMinutes":292,"category":932,"project":923,"tags":1374,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Ftwo-hands-on-one-lever","Two Hands on One Lever","Our deploy failed with a deploy already in progress while the commit it wanted to ship was going live. Anatomy of a benign collision and a trigger safe to lose.","2026-07-23T09:00:00.000Z","\u002Fimages\u002Fblog\u002Ftwo-hands-on-one-lever-hero.jpg",[936,969,1375],"observability",{"path":1377,"title":1378,"description":1379,"date":1380,"author":6,"image":907,"readingMinutes":292,"category":897,"project":923,"tags":1381,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Ftwo-hundred-and-fourteen-undecided","Two Hundred and Fourteen Undecided","An accessibility scanner found three faults on the dojo, we fixed them, and the next run came back clean. The clean run was the dangerous one.","2026-08-22T12:00:00.000Z",[1361,911,1052,926],{"path":1383,"title":1384,"description":1385,"date":1386,"author":6,"image":1387,"readingMinutes":106,"category":1006,"project":923,"tags":1388,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Ftwo-validators-one-gate","Two Validators, One Gate","Validating one request body twice, with a Fastify schema and again with Zod, is not double safety. The second gate is unreachable. One validator; Zod for types.","2026-07-07T22:06:46.000Z","\u002Fimages\u002Fblog\u002Ftwo-validators-one-gate-hero.jpg",[1052,1389,1390],"validation","typescript",{"path":1392,"title":1393,"description":1394,"date":1395,"author":6,"image":907,"readingMinutes":292,"category":897,"project":952,"tags":1396,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Funknown-is-an-answer","Unknown Is an Answer","A stub that holds no documents could not say whether a change still matched a query. Letting it answer unknown named a case the real database has too.","2026-09-29T09:00:00.000Z",[910,911,1052],{"path":1398,"title":1399,"description":1400,"date":1401,"author":6,"image":1402,"readingMinutes":287,"category":932,"project":923,"tags":1403,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fwe-are-all-doing-asynchronous-integration","We Are All Doing Asynchronous Integration","A continuous integration server is not continuous integration. The forge tests your work after you have moved on. Our build takes three minutes; no excuse.","2026-07-09T09:46:00.000Z","\u002Fimages\u002Fblog\u002Fasynchronous-integration-hero.jpg",[925,936,968],{"path":1405,"title":1406,"description":1407,"date":1408,"author":6,"image":907,"readingMinutes":292,"category":932,"project":933,"tags":1409,"draft":900,"series":938,"seriesOrder":292},"\u002Fblog\u002Fwe-promised-not-to-watch-then-we-needed-to-see","We Promised Not to Watch. Then We Needed to See.","Our privacy policy said no analytics, and it was true. Adding a page counter meant reopening that promise in public: what we chose, what we refused, one commit.","2026-08-24T10:00:00.000Z",[945,936,1410],"analytics",{"path":904,"title":5,"description":899,"date":898,"author":6,"image":902,"readingMinutes":307,"category":897,"project":905,"tags":1412,"draft":900,"series":907,"seriesOrder":907},[910,911,912,913],{"path":1414,"title":1415,"description":1416,"date":1417,"author":6,"image":1418,"readingMinutes":287,"category":932,"project":905,"tags":1419,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fwhat-one-real-release-taught-us","What One Real Release Taught Us in an Afternoon","Two crashes and one near miss within minutes of touching the real GitHub, because we had refused to fake it. The race we never met, the merge that never came.","2026-07-21T17:30:00.000Z","\u002Fimages\u002Fblog\u002Fone-real-release-hero.jpg",[1101,1420,910,1044],"failure-modes",{"path":1422,"title":1423,"description":1424,"date":1425,"author":6,"image":907,"readingMinutes":287,"category":932,"project":933,"tags":1426,"draft":900,"series":938,"seriesOrder":113},"\u002Fblog\u002Fwhat-the-link-looks-like-before-anyone-clicks","What the Link Looks Like Before Anyone Clicks","A link to any page on our site showed up as a bare rectangle of text. The share image is part of the page, and the build now puts one on every route.","2026-08-22T22:00:00.000Z",[935,936,937],{"path":1428,"title":1429,"description":1430,"date":1431,"author":6,"image":907,"readingMinutes":287,"category":897,"project":923,"tags":1432,"draft":900,"series":1153,"seriesOrder":106},"\u002Fblog\u002Fwhat-the-nullable-gave-back","What the Nullable Gave Back","One file, seven behaviours held fixed, the database swapped for a Nullable: about 180 times less time inside the tests, and coverage flat to two decimals.","2026-08-29T09:00:00.000Z",[911,910,1092,1152],{"path":1434,"title":1435,"description":1436,"date":1437,"author":6,"image":907,"readingMinutes":292,"category":932,"project":933,"tags":1438,"draft":900,"series":938,"seriesOrder":307},"\u002Fblog\u002Fwhat-we-count-and-what-we-refuse-to-know","What We Count, and What We Refuse to Know","Every system that holds anything about a visitor, in one table in the privacy policy. Writing it found a form that no longer existed and one that wrote twice.","2026-08-23T14:00:00.000Z",[945,936,1410],{"path":1440,"title":1441,"description":1442,"date":1443,"author":6,"image":1444,"readingMinutes":113,"category":1006,"project":907,"tags":907,"draft":900,"series":907,"seriesOrder":907},"\u002Fblog\u002Fwhy-good-software-starts-with-the-right-questions","Why Good Software Starts with the Right Questions","Before writing a line of code, the most impactful thing a team can do is ask better questions. Discovery is not a phase to rush. It is the foundation.","2026-01-15T09:00:00.000Z","\u002Fimages\u002Fblog\u002Fright-questions-hero.jpg",1793665011747]