
Stria
Catch scope creep before it becomes free work.
or eleven weeks the loop was: write code, run the checks, deploy, reload production, look at nothing.
Then I opened signups. And within about a day I realised something slightly absurd: nothing in my product would tell me if a stranger walked in.
Not "the analytics were misconfigured." There was no mechanism at all. My billing code sends a welcome email, but that fires on Stripe checkout and goes to the customer, so a free signup produced no signal anywhere. My Slack connection holds read scopes only and can't post. If someone had created an account on day one, I would have found out by manually querying my own database, which is a thing you do when you remember to, which is a thing you stop remembering to do.
So the first thing I shipped after launch was a doorbell for myself. The migration's first section is titled ## What did not exist, and the line under it reads: "Nothing told the founder that somebody had signed up." Writing that sentence about yourself, in the third person, in a SQL file, is a specific flavour of humbling.
Then I spent a genuinely stupid amount of time on when it should ring.
A row appears in auth.users the moment somebody submits the form, including everyone who never confirms their email. Alerting on insert would have made the notification mostly noise on day one, and noise is how an alert stops being read. So it fires on email_confirmed_at IS NOT NULL — the first moment a real person is definitely on the other end. I also put a 30-day floor on the query so the first run wouldn't email me every account that had ever existed.
I want to be honest about what that 30-day floor represents. I wrote a safety valve for a flood I had no evidence was coming.
The satisfaction is a lot smaller and a lot bigger than I expected
The first alert arrived. Somebody I have never met, who does not know me, typed their email into a form I had been staring at for a month, then went and found the confirmation link and clicked it.
I read that email about six times. Not for information. There was no information in it. Just: a human did this.
Every previous milestone was one I had granted myself. Tests passing is me agreeing with me. A clean deploy is me agreeing with me. This was the first thing in eleven weeks that came from outside the building.
A pricing decision I enforced in the schema rather than just on the pricing page.
The plans are flat monthly. Not per seat for clients, not per reviewer, not per document signed, not a percentage of anything invoiced. Clients and reviewers never appear on a bill because they never have an account.
Why this was non-negotiable: per-client pricing charges my customer for succeeding.
A freelancer whose client list grows is the exact person for whom the product is working, and metering them on it means the bill arrives at the moment they are most sensitive to it. It also puts a decision in front of them every time they add a client, which is a decision I do not want them to have.
Per-signature pricing is worse. The product exists to get a change order signed. Charging per signature is charging for the outcome the product is supposed to make effortless, and it teaches people to avoid the core action.
Then a specific one that gets skipped: the free tier is a standing plan, not a trial. It does not expire and it does not ask for a card. A free tier that quietly converts to a paywall after fourteen days is a trial wearing a costume, and it costs more trust than it earns revenue.
The plan limits live in one module that both the marketing site and the server read, so the pricing page cannot advertise a cap the server does not enforce, in either direction. Competitor prices quoted anywhere in the repo carry a recorded date and expire after 120 days — a stale competitor price is a false claim with a delay on it.
The tradeoff: I have given up the revenue expansion that per-seat pricing produces automatically, and I have to earn upgrades with capability instead of with growth taxes. That is harder. It is also the only version of this I would want to defend to a customer.
Has anyone moved off per-seat pricing deliberately, and did the expansion revenue actually come from somewhere else?
1 Like
Comment
382 migrations. Most of the rules that matter aren't in my app code at all — they're triggers, constraints, RLS policies and SQL functions, tested with pgTAP.
The reasoning is uncomfortable but simple: anything enforced in the client is a suggestion. My product sells tamper-evident approval records. If a sign-off's immutability lives in a React component, it isn't immutable, it's polite.
So the update and delete triggers on a completed sign-off refuse for everyone, table owner included. Deposit gating withholds deliverable files at the point where the signed download URL is minted, not in the UI, and it fails closed on error. Seat limits are a trigger on the members table, not a check in a hook.
That pushes testing down with it. Vitest covers 531 files' worth of app logic. The rules live in pgTAP suites that run against a real Postgres in CI, because a mocked trigger proves nothing.
Two things I learned the hard way.
A test suite can be dark. I used mnemonic UUIDs in fixtures for readability. A UUID is hex, and letters like n, y, i and p aren't. Postgres rejected the insert — but the insert was in the fixtures, so the suite aborted before its first assertion and reported no failure and no result. 24 suites were in that state. Not randomly distributed: milestone billing, currency consolidation, the dunning ladder, money webhook events. The features people adopt the product for were the ones with no executing coverage. 125 literals repaired, and the gate now also asserts every suite declares a plan and calls finish(), so a suite can't ship unable to fail.
Global coverage numbers lie in a specific direction. My repo-wide floor is 22% lines, which is honest for a codebase dominated by page components and useless for lateFee.ts. An average lets a 100%-covered tax module and a 0%-covered page swap places without moving the number. So the arithmetic that lands on an invoice has per-module floors — invoice composition, tax, late fees, deposits, retainers, receivables — and the check also fails when a new money-shaped module appears in src/lib with no floor, because a hand-maintained list of important files rots by addition.
2 Likes
3 Comments
3 Comments
-
1The “dark test suite” finding is more concerning than the 22% coverage number. Curious whether you now treat executability of the critical tests as a separate invariant from coverage itself.
-
1
Yes, and you've named the distinction I'd defend: coverage is a percentage, executability is a per-suite fact, and the second can regress while the first improves.
Two layers now. The 22% is a deliberately dumb global ratchet dominated by page components and generated types, and its only job is to stop the whole repo sliding. I don't treat it as information about any individual module. The real gates are per-module floors on the money path, generated from a single manifest that both the Vitest config and a separate post-hoc check read, so the runner and the auditor can't disagree about what's gated or at what level. That second check also fails when a new money-shaped module shows up with no floor at all, which is the case a threshold can't express.
Executability is enforced separately and earlier. Before the database even starts, CI verifies a named allowlist of 13 pgTAP suites is present: sign-off flow, Stripe webhooks, org_id immutability, dunning ladder, invoice idempotency, version-hash canonicalisation. A missing file fails the build on its own error. The suite is auto-discovered rather than registered, which kills the commonest dark vector: there's no list anyone can forget to add to. 281 SQL suites, and zero skipped or
.onlytests in the TS suite.The edge I haven't closed: presence isn't assertion. A hollowed-out suite with a dropped
plan()count would still pass that check. Pinning expected assertion counts per gating suite is the obvious next move.-
1That’s a useful distinction. It’ll be interesting to see whether the assertion-count check closes the remaining gap once you put it in place.
-
-
React 18 + Vite 7 + TypeScript + Tailwind, Supabase for Postgres and auth, Edge Functions on Deno. That's the whole thing.
What's deliberately absent, and what replaced it:
No component library. Tailwind plus a small set of local primitives. The tradeoff is real: I hand-rolled focus management and I've paid for it. What I got back is that a contrast gate can prove the four ink steps are correct, and a second check bans opacity-modified text colours like
text-charcoal/40— which rendered at 2.40:1, under even the 3:1 large-text floor. Alpha applied at the call site is invisible to a token-level check by construction. You can't write that guard against someone else's theme system.No state manager. TanStack Query is the cache; server state is the only state that matters here. Nothing has needed Redux or Zustand.
No ORM. Postgres functions called as RPCs. The cost is that Supabase takes an RPC name as a free string, so a typo compiles and fails at runtime as a 404. So there's a coverage ratchet on it: of the 246 RPC names the browser reaches, 46 had neither generated types nor a contract. The gate caps that list and fails when a 47th appears.
No form library, no date library.
Two honest concessions. pdfjs-dist and pptxgenjs are heavy, and they're there because clients get PDFs and decks. And there are about 40 devDependencies, so this is a claim about what ships to a browser, not about my node_modules.
The npm audit gate is the one place I'd push back on conventional advice. A bare audit gate is unusable the first time an advisory ships with no patched version: it goes permanently red, and npm audit fix --force "resolves" it by downgrading you into older CVEs. Mine fails on any unreviewed moderate-or-higher advisory, and also fails when a written exception expires or outlives its advisory.
Lean deps aren't a virtue on their own. What they buy is the ability to write checks about your own code, because you own the layer the check would inspect.
1 Like
Comment
Not linting. Not types. Twenty bespoke check-*.mjs scripts, each written the day after something broke in a way no normal test could have caught. The pattern is always the same: a decision recorded in prose, and a call site that reads fine.
A sample, all real:
A cron schedule inside a block comment. The retainer billing job was scheduled in SQL wrapped in /* */. It never ran. Meanwhile the in-app automation health panel advertised the job as active, with the impact line "Retainers will not bill automatically." So a freelancer set up a monthly retainer and it silently never invoiced. The part that makes this a guard rather than a fix: grep cron.schedule finds the commented-out version and looks reassuring. A machine has to tell executed SQL from documented SQL.
A Postgres COMMENT nobody could read. A migration retired a column and documented it perfectly: "read by nothing. Do not add a reader." Two readers were added anyway, months apart. Both call sites look completely ordinary — client.deal_value is a real field on a real type — while the knowledge that it's dead lives 300 lines into a SQL file nobody opens. One of the two silently forecast zero pipeline in the 90-day cash-flow panel on every workspace. So now no file under src/ may read a column whose COMMENT says DEAD.
An HTML entity inside a JS string. A page shipped 'You’re confirmed' inside a ternary. React escapes it, so the screen a buyer sees immediately after agreeing to pay a deposit read the markup out loud. In JSX text those same nine characters are correct. That's what makes it a defect class instead of a typo: the distinction is invisible while typing and invisible in review.
An endpoint documented nowhere. A token-authenticated ingest function shipped live and appeared in no OpenAPI spec. That's OWASP API9:2023, not a docs nit — an undocumented endpoint gets no contract test, no review, and can't be deprecated on purpose.
The meta-lesson I keep relearning: a comment addressed to a future human is decoration. If a decision matters, something has to read it.
1 Like
Comment
I see a lot of posts on Reddit and other forums about solo founders making all these cool products, which is great, but I feel like there is not enough coverage of the inverse of those scenarios. The late nights cramming in another feature, or writing up listing packages for Product Hunt and every other directory on the face of the Earth. I got into this business to solve problems and I started building Stria to solve problems that I have faced in the past and that I know there are lots of people currently facing them as well. I never realized how lonely the journey can be sometimes and how you can be your own harshest critic.
If Stria becomes what I truly believe it can be, then at that point it will all have been worth it, but until then I guess I'll just take it one day at a time. I know I'm not the only one feeling this way so maybe that's an app idea in of itself....a social network for lonely solo founders...just kidding!
1 Like
Comment
I have spent a lot of time building focused Windows utilities and selling them through the Microsoft Store under Automata Labs.
That experience taught me how to find narrow problems, ship a reliable solution, make a listing understandable, support users, and keep improving an app after launch. It has generated revenue.
But building a SaaS is a different challenge.
With a small Windows utility, the job is often: identify a concrete annoyance someone is already searching for, fix it cleanly, show the result in screenshots, and earn enough trust for a one-time purchase.
With SaaS, I need to earn recurring trust. I need a user to believe the product belongs in their workflow every week, not merely that it solves a one-off problem.
My current attempt is Stria, a client-work platform for freelancers, independent consultants, and small studios.
The entry point is deliberately narrow: scope creep often arrives as a client email—“Could you just add…”, “While you are in there…”, or “Can we squeeze in one more revision?” The freelancer has to decide, often quickly, whether the request belongs in the original agreement or needs a revised price, timeline, and approval.
I am building an AI-assisted workflow where a freelancer forwards that request, compares it to the approved scope, and gets an evidence-based starting point: likely in scope, likely out of scope, or unclear and requiring review. The broader product also handles the surrounding workflow: proposals, approvals, projects, invoices, and payments.
I recently launched and the honest starting numbers are not glamorous: I have put the product on the major directories, written initial content, and posted in relevant communities—but I am still trying to earn the first activated users without buying ads.
That is what I am going to document here:
Which free distribution experiments create qualified visits rather than vanity traffic
Whether freelancers trust an AI-assisted scope check enough to use it before replying to clients
Whether “scope creep prevention” is a strong enough wedge to introduce a broader freelancer operating system
Which features matter once someone has actually signed up
My working assumption is that the product does not need to be everything to everyone on day one. It needs to solve one expensive, repeated moment so clearly that a freelancer thinks: “I wish I had checked this before I said yes.”
Question for other founders: if you have moved from paid one-time software to SaaS, what most surprised you about acquiring and retaining the first users?
I’ll share the experiments—especially the ones that fail—as I run them. Stria is at https://getstria.com.
1 Like
Comment
A few years of freelance/contract work taught me something annoying: the actual work was fine, but the business layer was chaos. Scope lived in email threads. Approvals were "did they ever reply to that?" Invoices were a separate tool I'd update from memory at month-end. I lost money twice to scope creep I couldn't prove.
So I built Stria — one system that runs a client engagement from scope to payment: clients, projects, deliverables, client feedback/approvals, invoicing, and payments via Stripe Connect. The client gets a feedback loop built in, so approvals are timestamped instead of buried in email.
About
I got burned a few times by scope creep and got tired of absorbing costs instead of locking in the project scope and requiring change orders.


Comment