Tag: workflow

  • The three-frame Figma rule

    The three-frame Figma rule

    Every Monday at our design review, someone opens Figma and scrolls. And scrolls. Twenty frames, six alternate flows, a stray Miro embed, three sticky notes with question marks. By frame nine, nobody remembers what the proposal is for.

    We made a rule. Three frames. That is the budget.

    Why three frames

    Three frames force a story: state, action, result. You cannot fake that shape. If you cannot show the current state, the moment of change, and the outcome inside three frames, the idea is not ready. It might be interesting, but it is not buildable yet.

    We use the rule for anything that lands in our Linear “Ready for eng” column. Before a ticket moves out of Design, the linked Figma file needs:

    • Frame 1: the state the user is in when this matters
    • Frame 2: the action or decision point
    • Frame 3: what changes, what breaks, what they see next

    Three frames is not a rule about fidelity. Lo-fi wireframes count. Screenshots with red boxes count. Three annotated hand drawings count. The constraint is narrative, not craft.

    What the rule caught

    The first month we tried this, our design lead pulled six proposals back to discovery. One of them was a payments refactor that had ninety-two frames and no clear before or after. When she asked the designer to pick three, he could not. The empty state was fine. The success state was fine. The middle was a fog of edge cases and dependency guesses. That fog was the real work, and it was invisible under all the surface polish.

    Now, when a designer says they cannot get under three frames, we treat it as a signal, not a failure. It tends to mean one of these:

    1. The problem is two problems glued together, and we should split the ticket
    2. The team has not agreed on which user is being served
    3. We are drawing solutions before we have named the decision

    When we break it

    We break the rule for onboarding flows, migration paths, and anything with a compliance step that legal needs to review verbatim. Those are storyboards, not proposals. The distinction matters. A storyboard documents a sequence. A proposal argues for a change. Three frames is for the arguing.

    We keep the frame counter in the corner of every design review agenda. Nobody enforces it. It sits there, and the room reads it.

  • The debugging log we always regret losing

    The debugging log we always regret losing

    Every engineer on our team keeps a file called bug.md or scratch.txt or something equally forgettable. It lives in a folder that never gets checked into git. Nobody told us to make it. We all did anyway, because the third time a hard bug ate a full Thursday, we figured out that memory is the worst debugging tool we own.

    The file is append-only by convention, never by tooling. New entries go at the bottom with a timestamp and whatever we were poking at when the thought landed. When the bug closes, the log stays. When a laptop dies or we forget to sync it to iCloud, we mourn. One of us lost eight months of notes last October and still brings it up in retros.

    Here are three excerpts from the past quarter, lightly sanitized, that show why we would rather lose a Figma file than lose one of these.

    Excerpt one: the phantom timeout

    Tuesday, 2:14pm. Datadog spiked p95 on the checkout service to 8.2s. No deploys in 48 hours. Two engineers pulled in from a payments meeting.

    14:16 - checked prod dashboard, only /orders POST spiking, /orders GET fine
    14:19 - pulled last 500 traces, 91% share a redis GET on session token
    14:23 - ran KEYS scan against staging, 1.2M sessions cached from Friday load test
    14:27 - Marcus reminded me eviction policy on this cluster is noeviction
    14:31 - confirmed via redis-cli config get maxmemory-policy
    14:33 - shipping maxmemory-policy allkeys-lru now, PR #4821
    14:58 - p95 back to 340ms
    

    Six lines, one closed Linear ticket, one lesson written into our runbook. If we had captured a screenshot of the Datadog spike and nothing else, we would have a picture of the pain and none of the path out. Three months from now, when p95 climbs again on a Friday afternoon, this log will be the first thing we grep.

    Excerpt two: the flaky test that was not flaky

    This one ran across five days and two engineers. The Linear ticket collected fourteen comments. The log had forty entries. Only one of them mattered.

    Wed 10:04 - test_billing_webhook_signature failing on CI, passes local
    Wed 10:22 - retried 3x on CI, one green one red one red
    Wed 11:40 - added extra logging, deployed to CI branch
    Wed 14:15 - noticed failures happen only on runners pulled after 13:00 UTC
    Thu 09:30 - runner image was rebuilt yesterday, openssl bumped to 3.2
    Thu 09:45 - openssl 3.2 changed HMAC digest default padding behavior
    Thu 10:12 - our webhook signer never set padding explicitly, tests inherited default
    Fri 16:20 - PR #4903 pins padding, backports to test util
    

    The Wednesday 14:15 note is the whole thing. It is a stray observation, not a fix. If we had been operating inside a Slack thread and a screenshot pipeline, that observation would have been buried under seventeen emoji reactions and lost forever. In the log, it sits alone on a line, waiting to be reread on Thursday morning when it finally clicks.

    Excerpt three: the null we could not explain

    Mon 08:50 - user report: dashboard renders "customer name: null" for 4 accounts
    Mon 09:10 - all 4 accounts created between Aug 3 and Aug 5
    Mon 09:12 - Aug 3 was our migration cutover date
    Mon 09:14 - checked backfill script, filters WHERE created_at < '2025-08-03'
    Mon 09:15 - accounts created ON Aug 3 fell into the gap
    Mon 09:20 - one-off patch queued, wrote regression test, added to postmortem
    

    Twenty minutes, start to finish. A screenshot would have told us the UI was broken. The log told us we shipped a boundary bug in a migration script, and reminded us to check for the same class of mistake in the next migration on the roadmap.

    Why context beats screenshots

    Screenshots capture a state. Logs capture a decision tree. When we return to a screenshot, we see what we saw. When we return to a log, we see what we tried, what we ruled out, and what surprised us. The last of those is the reason we keep writing these files.

    A short list of what belongs in the log:

    • The exact command we ran, copy-pasted with its output
    • The question we were asking when we ran it
    • What we expected and what we got instead
    • Any stray observation that felt off but was not the bug
    • The commit hash, PR number, or Linear ID at the end

    A shorter list of what does not belong:

    • Long paragraphs
    • Screenshots
    • Anything we have to format before saving

    The rule that keeps the log useful is that it must be faster to add a line than to think about not adding it. The moment it feels like writing, it stops getting written. That is why nobody on our team keeps this file in Notion. Notion asks us to name the page, pick a template, and choose an emoji. By then the thought is gone.

    We have started nudging new engineers to keep one within their first week. Not as a policy. As a warning. On the day their laptop dies, they will remember which files they miss most. This is always one of them.

  • How twelve of us run trunk-based development

    How twelve of us run trunk-based development

    We moved to trunk-based development eighteen months ago, when the team was seven engineers and the release train was groaning under its own weight. We are twelve now, split across three squads, and every one of us commits to main multiple times a day. What follows is the routine we settled into, and the two things that caught us off guard.

    None of this is theoretical. It is what we do between our Monday planning meeting and our Friday demo, using GitHub, Linear, Datadog, and a lot of small pull requests.

    The daily rhythm

    Our workflow rests on three habits that we practice without thinking about them now. When a new engineer joins, these are the three things we teach in the first week.

    • Feature flags before feature code. Every user-visible change ships behind a flag in LaunchDarkly. The flag lands in a separate PR, sometimes an hour before the feature work starts. That way the wiring is reviewed on its own, and the rollout is a config change rather than a deploy.
    • Branches that live less than a day. Our house rule is that a branch should not sleep. If the sun sets on your branch, you either open a draft PR to get eyes on it, or you merge whatever green subset you have behind a flag. We measure this: last quarter, the median branch age was 6 hours and 42 minutes.
    • Five-minute code review triage. At 10:15 and 15:15, whoever is on review rotation opens the GitHub PR queue and gives every open PR one of three responses: approved, one specific question, or a note that the review will land by end of day. No PR is allowed to sit without a signal for more than four hours during working time.

    The triage rule is the one that took the most discipline to adopt. Two twelve-minute windows a day sounds trivial, but it forced us to break up large PRs. If you cannot skim a diff in ninety seconds, the reviewer flags it and asks for a split. We settled on a soft cap of 400 lines of diff, and a hard cap of 800.

    What we do when things break

    Trunk-based development only works if main is trustworthy. Ours is protected by a required check that runs unit tests, a smoke suite against a staging environment, and a Datadog synthetic against three critical paths. When the check goes red, whoever pushed the offending commit has two options: revert within fifteen minutes, or roll forward within thirty. We track this in a Notion page called the Green Log. Since January, we have had eleven red-main incidents, and nine of them were resolved by revert.

    We optimise for the next commit, not the current one. A revert is not a failure. A stuck main is.

    What surprised us

    We expected the obvious wins: faster feedback, smaller blast radius, less time spent on release branches. Those all showed up. What we did not predict were two second-order effects that changed how the team behaves.

    Merge conflicts became rare. We assumed twelve people pushing to one branch would create a merge conflict every few hours. In practice, the opposite happened. Because everyone rebases against a fresh main multiple times a day, conflicts surface early, when the diffs are small and the context is still in someone’s head. Our GitHub metrics show a conflict rate of roughly one PR in forty, down from one in eight when we were running two-week release branches. The conflicts we do get are almost always resolved in under ten minutes by the author.

    Design conversations moved earlier. This is the change we care about most. When branches lived for a week, most technical debate happened at review time, when the code was already written and the author was invested. Now, because a branch cannot survive a day, engineers ask for a design opinion before they open the editor. Our #eng-design Slack channel used to see two or three threads a week. It now sees eight or nine, and most of them are fifteen-minute exchanges about approach, not aesthetics. A few times a month, one of those threads turns into a Figma sketch or a Linear ticket for a spike.

    We think the mechanism is simple. Short branches make the cost of throwing away code visible. If you have to justify a day of work, you tolerate risk. If you only have to justify two hours, you ask the question first.

    What we would tell a team starting today

    If we were setting this up again from scratch, we would do these things in this order:

    1. Put LaunchDarkly, or a homegrown equivalent, in place before you change any branching rules. Without flags, small merges are frightening.
    2. Write down the review triage times and defend them. Ours are on the team calendar as recurring events.
    3. Pick a diff-size cap and enforce it socially. Numbers matter less than the shared expectation that big PRs get split.
    4. Track red-main minutes as a team metric, not an individual one. We share the Green Log in our Friday retro.

    Trunk-based development is not a productivity trick. It is a set of constraints that make the team’s default behaviour better. Eighteen months in, we are shipping about twice as often as we used to, and the conversations we have about how to build things are earlier and more useful. Those two outcomes are worth more to us than any of the workflow mechanics that produce them.

  • The five-day slice rule

    The five-day slice rule

    Every Monday at 10:15 our delivery lead opens Linear and runs a filter called Cycle Ready. If a ticket in the active cycle fails the filter, it gets flagged red and the ticket owner has until standup Tuesday to fix it or pull it. The filter checks one thing: can a single engineer start work Monday morning and demo something running by Friday afternoon.

    We call this the five-day slice rule. It has been the single biggest change to how our team ships since we moved off two-week sprints eighteen months ago.

    Why five days, not ten

    We tried ten-day cycles for most of 2024. Tickets came in sized small, medium, or large, and engineers estimated them in points. By day seven of a typical cycle, roughly a third of medium tickets were still labelled In Progress with no visible artifact. Reviewers had nothing to look at. QA had nothing to queue. The last three days always became a scramble.

    The pattern was consistent enough that we started tracking it in Datadog against our Linear webhook data. Tickets that produced no reviewable artifact by day three of a ten-day cycle had a 72 percent chance of slipping the cycle. Tickets that did produce something by day three slipped 8 percent of the time. The signal was loud.

    We cut the cycle in half and enforced a hard rule on entry: one person, Monday start, Friday demo. If it does not fit, split it before the cycle starts, not during.

    How we enforce the rule

    The rule lives in three places:

    • A Linear template with a required field called Friday demo artifact. Engineers cannot move a ticket into Ready for Cycle without filling it in. Sample entries: a PR merged behind a flag and hitting a staging endpoint; a Grafana panel showing p95 for the new route; a Loom of the empty state rendering with fixture data.
    • A pre-cycle review meeting on Friday afternoon called Slice Check. Twenty five minutes, four people: engineering manager, tech lead, product manager, delivery lead. We read the Friday demo artifact field for every candidate ticket. If anyone at the table cannot picture the demo, the ticket does not enter.
    • A Slack bot posting into the delivery channel every Wednesday at 4pm with the list of active-cycle tickets that have no PR opened and no draft artifact linked. The message pings the ticket owner directly.

    The bot is the piece that took the longest to trust. We tuned it for six weeks before people stopped arguing with it. The current heuristic: no draft PR, no Loom link, no Notion doc updated in the last 48 hours, and the ticket is past cycle midpoint. Three signals, one ping.

    What happens when a team pushes back

    The rule gets fought. Usually by whichever team is holding the largest piece of unsplit work. We have heard every version of the objection:

    You cannot split a database migration into five-day slices. The migration either runs or it does not.

    The auth rewrite is one atomic change. Splitting it means shipping something broken.

    We are being asked to do more planning work than shipping work.

    We take these seriously and we still hold the line. Every migration we have run in the last year has split. Every auth change has split. The planning cost is real and it front-loads. Our data shows the front-loaded planning cost is roughly 90 minutes per split ticket, and it saves an average of 6 hours of end-of-cycle scramble per unsplit ticket that slips.

    When a team insists a piece of work cannot split, we sit down with them for a 30 minute session with a whiteboard and the delivery lead. We have run this session 41 times. It has produced a valid split 39 times. The two exceptions were a vendor cutover with an external deadline and a hotfix that shipped inside a day.

    A concrete example: splitting the profile export ticket

    Last quarter we had a ticket that read: add data export for user profiles, including preferences, integrations, activity history, and audit logs, downloadable as a signed ZIP. Original estimate: two weeks. Owner: one engineer on the Growth pod.

    Under the old rules this would have entered a cycle whole. Under the slice rule we ran it through Slice Check on the Friday before, and split it into three tickets:

    1. Slice one, week of Jan 13. Endpoint scaffold plus preferences payload. Friday demo: hit POST /exports/profile in staging, receive a signed URL, download a ZIP containing a single preferences.json file. Behind a flag. One engineer, five days.
    2. Slice two, week of Jan 20. Add integrations and activity history to the payload. Friday demo: same endpoint, same flag, ZIP now contains three files. Handles the 90 percent case of activity records fitting in a single query batch.
    3. Slice three, week of Feb 3. Audit logs, pagination for large history sets, signed URL expiry policy, and flag flip. Friday demo: end to end run for a real customer account with 40k audit rows, timing recorded in the demo doc.

    We put a two-week gap between slice two and slice three on purpose. That gap ran a quiet beta with three friendly customers on slice two, and the feedback moved the audit log format before we built it.

    Total calendar time: roughly the same as the original two-week estimate would have been had it not slipped. The difference is that we had something demoable at three checkpoints instead of one hopeful checkpoint at the end.

    What we track

    We keep three numbers on a Notion page called Delivery Health:

    • Percent of active-cycle tickets that hit their Friday demo artifact. Current: 88 percent, target 85.
    • Median time from a ticket entering Ready for Cycle to its first PR opened. Current: 1.2 days.
    • Number of tickets that entered a cycle unsplit and slipped. Current: 2 this quarter, down from 14 the same quarter last year.

    The rule is not magic. It is a constraint that forces the planning conversation to happen on Friday instead of Wednesday of week two, when a slip is already priced in. If the demo cannot be pictured on Friday, the work is not ready. That is the whole rule.

  • What done means for a task on our team

    What done means for a task on our team

    Every team we worked on before Velo had a definition of done pinned to a wiki page nobody read. Ours did too, until a Wednesday standup in March when Priya asked whether the invoice retry work was finished, and four engineers gave four different answers. That morning cost us a customer refund and a two hour incident review. We decided the Notion page was not the problem. The definition was.

    Three tries that did not stick

    Our first attempt was a paragraph in Notion titled “shipping standards” that said tasks should be “merged, tested, and reviewed.” It read fine on the page. In practice, “tested” meant whatever the author felt like: a unit test, a manual walkthrough, or nothing if the diff was under twenty lines. We shipped a race condition in the billing worker three weeks later because the author had run the change against a fresh database and assumed that counted.

    The second attempt was a Linear checklist template with nine items. Everyone checked every box, because the boxes were reported by the author and the reviewer had no way to verify half of them without opening five other tabs. The checklist became a ritual, then a joke, then a template we quietly stopped applying to new tickets.

    The third attempt was strict: a task was done when a designated QA engineer signed off in a Slack thread. This lasted eleven days. Our QA lead, Ruth, went on holiday, and the queue backed up to forty two tickets. When she came back, half the context was gone and she had to re verify work from memory. We had traded ambiguity for a bottleneck.

    The four criteria we settled on

    After the third failure, we spent a Friday afternoon working through what we needed the definition to do. It had to be verifiable by someone other than the author, it had to survive one person being out, and it had to answer the question Priya asked in March without a debate. We landed on four criteria, in this order:

    • Works: the change does what the ticket says, verified against the acceptance criteria written before the branch was cut. If those criteria were vague, that gets fixed before the ticket moves to review, not after.
    • Tested: automated coverage exists for the new behavior, and the tests fail without the change. The reviewer runs the suite locally or points at a green CI badge tied to the merge commit.
    • Deployed: the change is live in production, not staging, not behind a flag that has never been flipped on for a real user. If the work sits behind a flag, done waits until the flag is on for the intended audience.
    • Observed: a human has confirmed the change behaves as expected in production, using logs, a Datadog dashboard, or a real user event. Not a synthetic ping. A trace of the feature being used, or a metric moving in the direction we predicted.

    The order matters. If “works” is unclear, testing the wrong thing is worse than not testing. If we skip “deployed” and call something done at merge, we hide half our incidents in the gap between main and production.

    The compromise on observed

    Observed was the criterion that almost killed the whole definition. Half the team pointed out, correctly, that internal only changes have no production traffic to watch. A new admin report, a migration script, an internal CLI: none of these throw off metrics on the customer dashboards we use for observability. Waiting for a real user event on an internal tool would mean waiting forever, or fabricating one.

    We debated dropping the criterion for internal work. We tried, for a sprint. Two internal tools broke silently and we found out from a support agent who could not load the refunds page. The criterion needed to survive.

    The compromise: for internal only changes, observed means the author or a teammate has used the feature in production for its intended purpose, with a Loom or a screenshot posted to the ticket. Not tested it. Used it. If the ticket is a migration, the observation is the query result after the migration ran. If it is a CLI, it is the terminal output from a real invocation on the real database.

    The distinction we care about is between “I believe this works” and “this has done its job for a real person, once.” The Loom feels heavy the first time. It stops feeling heavy the second time somebody catches a broken admin page before a customer does.

    How the four criteria show up in our week

    Every ticket in Linear now has four checkboxes matching the criteria. The author checks the first three. The reviewer, or on internal changes any teammate, checks observed and pastes the evidence. Our Monday planning meeting starts by pulling the list of tickets marked done in the last week and skimming the observation links. It takes eight minutes. In the six months since we adopted this, we have had two rollback situations that a proper observation caught before the on call engineer noticed. We have also had one case where the observation link was a screenshot of the wrong environment, which is a different problem, and one we are still working on.

    We do not think this definition is universal. It is what our team of eleven engineers, on a codebase with sixteen deploys a week, needs to keep the wiki page honest. If the shape of the team changes, we expect the definition to change with it.