[{"data":1,"prerenderedAt":299},["ShallowReactive",2],{"blog-en-second-sp-was-not-designed":3,"header-blog-translations-/en/blog/second-sp-was-not-designed":296},{"id":4,"title":5,"author":6,"body":7,"date":280,"description":281,"draft":282,"extension":283,"image":284,"meta":285,"navigation":192,"path":286,"seo":287,"stem":288,"tags":289,"translationKey":294,"__hash__":295},"blog_en/blog/en/second-sp-was-not-designed.md","I Didn't Design the Second Service Provider","Patrick Hofmann",{"type":8,"value":9,"toc":273},"minimark",[10,23,35,40,43,54,57,61,64,82,97,101,104,107,111,114,124,147,206,221,224,228,231,238,244,247,269],[11,12,13,14,18,19,22],"p",{},"My activity logs feed the timesheets per project and company. So far that was a ",[15,16,17],"code",{},"jq"," pipeline over JSONL — works, but I can't open it on my phone and ask how many hours ran on a specific project in May. So ",[15,20,21],{},"timetrack.openape.ai",".",[11,24,25,26,29,30,34],{},"The honest part: I didn't sit down and design a service. I copied ",[15,27,28],{},"openape-tasks"," and renamed it. The first commit in the new repo literally reads ",[31,32,33],"em",{},"\"mirror openape-tasks, rename to timetrack\"",". That's not a typo in the commit message, that's the method.",[36,37,39],"h2",{"id":38},"how-the-first-service-provider-was","How the first service provider was",[11,41,42],{},"Invented. Every building block was a decision made once, painfully, with smoke tests that found five chained bugs at once in the first real dogfooding.",[11,44,45,46,49,50,53],{},"DDISA discovery: an SP doesn't register in the IdP, it publishes its metadata over DNS. Token exchange: RFC 8693, with the delegation-grant-only path for the case where the caller doesn't have both tokens. The two-tier RBAC. The ",[15,47,48],{},"ape-*"," CLI pattern with ",[15,51,52],{},"--json"," for agent-scriptable output. Each of these was work where I didn't know how it would turn out until it turned out.",[11,55,56],{},"That's the expensive part. You do it once.",[36,58,60],{"id":59},"how-timetrack-was","How timetrack was",[11,62,63],{},"The actual work was the domain: companies and projects, who sees which entries, how a report groups by project. The two-tier RBAC had to be laid onto the new data model — a visibility function plus the tests that probe the matrix from the spec.",[11,65,66,67,70,71,74,75,78,79,81],{},"What was ",[31,68,69],{},"not"," a step: the identity layer. There's no milestone \"design auth\". Not because I forgot it, but because that layer is the same in every SP. DDISA discovery, token exchange, the auth-plus-exchange-route pair — copied, ",[15,72,73],{},"aud","/",[15,76,77],{},"iss"," pulled to ",[15,80,21],{},", done. The first real agent E2E against prod was green.",[11,83,84,85,88,89,92,93,96],{},"The friction that actually cost time wasn't in the SP. ",[15,86,87],{},"ape-timetrack companies use"," against localhost persisted the ",[15,90,91],{},"activeEndpoint",", a local state that let a later run against prod die as \"HTTP 0\". Two misdiagnoses — first ",[15,94,95],{},"cli-auth",", then Node 25 — before the real reason was on the table: test pollution of the CLI state, nothing about the protocol. That's the point about the friction: it was in the local state, not in the architecture. The architecture behaved mechanically.",[36,98,100],{"id":99},"what-fell-away","What fell away",[11,102,103],{},"The inventing. There's no longer a point where I think about how an SP authenticates to the IdP. That question is answered, and the answer is the same everywhere.",[11,105,106],{},"That's exactly what makes a new SP copyable instead of inventable. And that's exactly where it tips.",[36,108,110],{"id":109},"copyable-also-means-assumptions-get-copied-along","Copyable also means: assumptions get copied along",[11,112,113],{},"In the same week three commits with identical messages landed in five repos:",[115,116,121],"pre",{"className":117,"code":119,"language":120},[118],"language-text","fix: resolve IdP issuer from subject DDISA, drop hardcoded issuer\nfeat: declare scope catalog in /.well-known/openape.json\nfeat: enforce delegated scopes (chokepoint + subset@exchange + short TTL)\n","text",[15,122,119],{"__ignoreMap":123},"",[11,125,126,128,129,128,132,128,135,138,139,142,143,146],{},[15,127,28],{},", ",[15,130,131],{},"openape-plans",[15,133,134],{},"openape-preview",[15,136,137],{},"openape-timetrack",", the ",[15,140,141],{},"sp-starter"," — five times the same, because the bug was the same five times. The issuer was hardcoded to ",[15,144,145],{},"https://id.openape.ai",". Conceptually:",[115,148,152],{"className":149,"code":150,"language":151,"meta":123,"style":123},"language-ts shiki shiki-themes material-theme-lighter material-theme material-theme-palenight","// before: the same in every SP, because copied from the first SP\nconst ISSUER = \"https://id.openape.ai\"\n\n// after: issuer from the _ddisa DNS record of the subject domain,\n// no longer hardwired\n","ts",[15,153,154,163,187,194,200],{"__ignoreMap":123},[155,156,159],"span",{"class":157,"line":158},"line",1,[155,160,162],{"class":161},"sHwdD","// before: the same in every SP, because copied from the first SP\n",[155,164,166,170,174,178,181,184],{"class":157,"line":165},2,[155,167,169],{"class":168},"spNyl","const",[155,171,173],{"class":172},"sTEyZ"," ISSUER ",[155,175,177],{"class":176},"sMK4o","=",[155,179,180],{"class":176}," \"",[155,182,145],{"class":183},"sfazB",[155,185,186],{"class":176},"\"\n",[155,188,190],{"class":157,"line":189},3,[155,191,193],{"emptyLinePlaceholder":192},true,"\n",[155,195,197],{"class":157,"line":196},4,[155,198,199],{"class":161},"// after: issuer from the _ddisa DNS record of the subject domain,\n",[155,201,203],{"class":157,"line":202},5,[155,204,205],{"class":161},"// no longer hardwired\n",[11,207,208,209,212,213,216,217,220],{},"The first SP had the hardcoded issuer. Every copy carried it along. Nobody tripped, because ",[15,210,211],{},"hofmann.eco"," points to ",[15,214,215],{},"id.openape.ai"," via ",[15,218,219],{},"_ddisa"," DNS anyway — the violation was behavior-preserving, hence invisible. It only surfaced when I wrote the SP Data Access Profile and audited across all five SPs.",[11,222,223],{},"An audit of the docs wouldn't have found it — the code was \"correct\" everywhere, because it was equally wrong everywhere. Only real cross-SP E2E found it.",[36,225,227],{"id":226},"the-cut","The cut",[11,229,230],{},"Inventing is one-time and expensive. Copying is cheap and uniform — for better and for worse. The uniform correct decision carries every new SP almost mechanically. The uniform wrong assumption reproduces just as mechanically, five times, and gets fixed with the same diff five times.",[11,232,233,234,237],{},"That the architecture is copyable is the win. The proof that it's ",[31,235,236],{},"really"," copyable is that the bug was too.",[11,239,240,241,243],{},"I made the ",[15,242,141],{}," public afterwards. Not because I had planned to build a template. I wanted a time tracker for my timesheets — and noticed in the process that the thing I copy from is now a thing instead of my memory of the last SP. A template is just more honest about what happens anyway: the next SP won't be invented.",[245,246],"hr",{},[11,248,249],{},[31,250,251,252,259,260,263,264,22],{},"Code: ",[253,254,258],"a",{"href":255,"rel":256},"https://github.com/openape-ai/openape",[257],"nofollow","github.com/openape-ai/openape",", MIT-licensed. The SP Data Access Profile lives in ",[15,261,262],{},"protocol",", the copy starting point in ",[253,265,268],{"href":266,"rel":267},"https://github.com/openape-ai/sp-starter",[257],"github.com/openape-ai/sp-starter",[270,271,272],"style",{},"html pre.shiki code .sHwdD, html code.shiki .sHwdD{--shiki-light:#90A4AE;--shiki-light-font-style:italic;--shiki-default:#546E7A;--shiki-default-font-style:italic;--shiki-dark:#676E95;--shiki-dark-font-style:italic}html pre.shiki code .spNyl, html code.shiki .spNyl{--shiki-light:#9C3EDA;--shiki-default:#C792EA;--shiki-dark:#C792EA}html pre.shiki code .sTEyZ, html code.shiki .sTEyZ{--shiki-light:#90A4AE;--shiki-default:#EEFFFF;--shiki-dark:#BABED8}html pre.shiki code .sMK4o, html code.shiki .sMK4o{--shiki-light:#39ADB5;--shiki-default:#89DDFF;--shiki-dark:#89DDFF}html pre.shiki code .sfazB, html code.shiki .sfazB{--shiki-light:#91B859;--shiki-default:#C3E88D;--shiki-dark:#C3E88D}html .light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}html.light .shiki span {color: var(--shiki-light);background: var(--shiki-light-bg);font-style: var(--shiki-light-font-style);font-weight: var(--shiki-light-font-weight);text-decoration: var(--shiki-light-text-decoration);}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":123,"searchDepth":165,"depth":165,"links":274},[275,276,277,278,279],{"id":38,"depth":165,"text":39},{"id":59,"depth":165,"text":60},{"id":99,"depth":165,"text":100},{"id":109,"depth":165,"text":110},{"id":226,"depth":165,"text":227},"2026-05-15","I wanted a time tracker for my own timesheets. What came out is proof that a new protocol-conformant service provider is no longer an invention, but a copy — with everything copying brings with it.",false,"md",null,{},"/blog/en/second-sp-was-not-designed",{"title":5,"description":281},"blog/en/second-sp-was-not-designed",[290,291,292,293],"OpenApe","Infrastructure","Architecture","Building in Public","second-sp-was-not-designed","4qstnEeQG4Dq8dHsHOqkvG6bUwudIZrb8dxLPziToYo",{"en":297,"de":298},"/en/blog/second-sp-was-not-designed","/de/blog/den-zweiten-service-provider-habe-ich-nicht-entworfen",1779001887139]