Building the social layer at Step.
Circles let people form a small group around shared activity. The Movement Feed is what that group actually sees. Making the two agree — who is a member, who may see what, and which activity becomes a feed card — was the engineering problem.
Problem
In plain terms: a user creates a circle, invites a few people, and from then on the group is supposed to see each other's activity. Members post, comment and react. When someone finishes a workout, that should turn up in the feed of the people who are allowed to see it — and nobody else.
That sentence hides the actual work. Membership changes over time: invitations are pending, accepted or declined, people leave, and a circle can be private. Visibility follows membership, so the answer to "who should see this card" is different every time it is asked. And the activity that produces a card does not arrive as a tidy request from the app — it arrives as events from the backend, which can be duplicated, delayed, or of a type nobody planned for.
So the central question for this work was not "how do I render a feed". It was: how do membership, visibility and feed events stay in agreement between a mobile client and an event-driven backend, when each of the three can change independently?
My role
Step's social features were built by a small team — two to three engineers plus the founder and QA — and a substantial share of the work is other people's. This is the part I can speak for, split by what I actually did rather than by what the feature list looks like. I am deliberately not quoting a commit share here: it would measure volume and read as a measure of contribution.
- I implementedsocial product surfaces in the iOS app and the backend handlers behind them — memberships, invitations, posts, comments and reactions.
- I implementedthe change that made feed generation react to the full set of confirmation events rather than one event type, together with reconciliation for activity that had already been missed.
- I proposed and ledthe contract between client and backend: which fields the client may trust, what the backend decides on its own, and what a client is never allowed to assert.
- I proposed and ledthe test scenarios for membership and visibility edge cases, and the case for keeping access decisions on the server when it would have been quicker to filter on the client.
- Shared with the teamdesign, product scope, release approval and the wider platform. I did not own those alone.
Key decision
Keep access rules on the backend, and make feed updates resilient to duplicate or missed events.
The tempting shortcut is to let the client decide what to show. The client already knows which circle the user opened and which members it just fetched, so filtering there is fast and easy. It is also wrong the moment the two disagree: a stale membership list on a device becomes a visibility bug, and the same rule then has to be re-implemented on every platform.
So the client asks and renders; the backend decides. The cost is real — an extra round trip in places where local filtering would have been instant, and more backend work per request. The benefit is that visibility has exactly one implementation, and a device holding old data can be wrong about what it shows but not about what it is allowed to see.
The second half of the decision is about events. If a feed card is produced by an event, then the interesting cases are the event that arrives twice and the event that never arrives at all. Both were treated as normal operating conditions rather than as faults: writes that produce cards are keyed so a repeat does not create a second card, and there is a path to reconcile activity that should have produced a card and did not.
How it works
The path below is the one that matters for this case study: a confirmed workout becoming a card in the right people's feeds. Feed cards are generated from database change streams rather than from a request the app makes, which is what gives this flow its failure modes. It is drawn at the level of responsibility — I am naming the mechanism, not publishing the internal wiring.
- Activity is confirmed
A workout is recorded in the app or imported from Apple Health, and the confirmation lands as a change in the database.
- A change stream carries the event
Stream triggers pick the change up and hand it to the aggregator that builds feed cards. Nothing in the user's request waits for this.
- The event is classified
The handler decides whether this change represents a confirmed activity worth publishing — across every event type that can carry that confirmation, not just the expected one.
- The audience is resolved
Circle membership and visibility are read on the backend to determine who is allowed to see this activity, at this moment.
- The card is written once
Feed entries are written with a deterministic identity and a guarded write, so a repeated event resolves to the same card instead of a duplicate.
- Clients read the feed
The iOS app requests the feed it is entitled to and renders it. Its own actions — a reaction, a comment — apply optimistically and roll back if the write does not hold. It never computes entitlement.
Six responsibilities, and the important thing is where they sit: the user's request ends at step 1. Everything from the change stream to the written card happens asynchronously on the backend, which is why a missed or repeated event is a correctness problem rather than a visible error.
Reading it as prose: the backend learns that an activity is confirmed, decides whether that confirmation is the kind that belongs in a feed, works out who may see it, writes the card in a way that survives the same event arriving again, and then the app simply asks for what it is allowed to read. The client's job is deliberately small.
The step that turned out to be interesting in production was classification. It is also the step that produced the bug worth telling you about.
The bug: a confirmation that the feed rule did not recognise
A workout imported from Apple Health was not confirmed the way a workout recorded in the app was. The import path produced its confirmation as a MODIFY event — the record already existed, and confirmation changed it. The feed rule fired on INSERT.
The result was not an error. Nothing crashed, nothing logged a failure, and every test that created a workout the ordinary way passed. The activity was simply never considered for the feed, so for the people whose workouts came from Apple Health the feed was quietly incomplete. This is the characteristic failure of the asynchronous path above: when the work that builds the card happens outside the user's request, work that never happens looks exactly like nothing happening.
The fix had three parts. First, handling was widened so that a confirmation is a confirmation regardless of which event type carries it — the rule now matches on what the event means, not on the one shape it happened to have first. Second, because activity had already been missed, a scheduled reconciliation job looks for confirmed activity with no corresponding card and produces the missing ones, so the same class of gap repairs itself rather than needing to be noticed. Third, that backfill had to be safe to run repeatedly: it can revisit the same activity without producing a second card, and it has an undo counterpart.
How well it worked, stated carefully: my account is that this eliminated the class of missed cards and that the reconciliation job now finds nothing left to backfill in normal operation. That steady-state observation is the one claim in this story with no source record behind it, so read it as my report rather than as a measurement — and note that a reconciliation job reporting nothing to fix is only as good as the query it uses to look.
What this is not: it is not an exactly-once guarantee for the platform. It is one class of event handled correctly, with duplicate-safe writes and a reconciliation net on that path. Explicit idempotency machinery exists on the paths that needed it rather than across the whole backend, and I am not claiming the rest were audited.
Why duplicate suppression lives in the write, not in a filter
Deduplicating when reading the feed would have been easier to ship: fetch, group, drop repeats. It also means the duplicate exists in storage, so every future reader has to know about it, and any process that counts or notifies sees two things where there was one.
Pushing the constraint into the write is the only place that can enforce it once for everyone. Concretely that meant deriving the card's identity deterministically from the activity rather than from the event that triggered it, guarding the write so a second attempt cannot create a second row, and using tombstones for deletes so a removed card cannot be resurrected by a late event.
The trade-off is that the identity has to be chosen correctly up front — too narrow and duplicates slip through, too broad and activity that deserved separate cards collapses into one. That choice is not reversible cheaply once cards exist, which is why it belonged in the design rather than in a later patch.
Verification
What is actually checked, and how. The statuses below are deliberately different from each other — a written test case, a point-in-time audit and an executed run are not the same evidence, and this section does not present them as if they were.
- Documented195 acceptance test cases for Circles V2, generated from the requirements
A generator turned the Circles V2 requirement documents into 195 acceptance cases and imported them into the test-management tool. Three things follow from that: they were generated rather than hand-written, they cover Circles V2 rather than the feed, and a count of imported cases is not a count of passing tests. No execution report for this set is presented here.
Source inspected, not independently verified · Step Experience Master Document §1.5 / §3.I / Appendix A6 · 2026-09-11
- Static auditA static code-vs-matrix audit: 108 of 155 checks satisfied, 47 gaps
On 2026-07-30 the acceptance matrix was compared against the code as it then stood: 108 checks satisfied, 47 not, which is about 70% implemented at that moment. The gaps became a fix backlog. This is a code audit rather than a test run, its 155 checks are a different artifact from the 195 imported cases, and nothing here claims all 47 gaps were later closed.
Source inspected, not independently verified · Circles V2 code-vs-matrix audit, 2026-07-30, cited in the Step Experience Master Document Appendix F4 / §7 · 2026-07-30
- DocumentedThe MODIFY/INSERT defect, its fix and the reconciliation net
The missed event type, the widened handling, the scheduled reconciliation job and the idempotent backfill are recorded in my own write-up of the work. The claim that nothing is left to backfill in steady state has no source record behind it even there — it is my observation, not a measurement, and no repository, log or test output for any of it is published on this site.
Source inspected, not independently verified · Step Experience Master Document §4 (story 6) / Appendix C2 · 2026-09-11
- Not presentedA current automated run of the social suite
Not presented. I have no published run of that suite to point at for this write-up, so there is no pass/fail figure for it here, and the audit above should not be read as one.
Outcome & limitations
What the work produced
- Circles and the Movement Feed shipped: people can create a circle, invite members, post, comment and react, and see each other's confirmed activity.
- Visibility has one implementation, on the backend. That is what the design is for: a client holding stale membership data can render an out-of-date list, but it has no path to widen its own access. No audit or penetration test is published here to prove the property holds in every case.
- Activity confirmed through an event type the feed rule had not covered now reaches the feed, and the backfill that repaired the already-missed activity was built to be safe to re-run rather than as a one-shot script.
- Load context, feature-scoped: the feed aggregator runs on the order of 34,000 times a day. That says how often the machinery turns over, not how many people the feature served and not whether it worked for them.
What this does not show
- No engagement, retention or revenue effect is claimed. I do not have those numbers, and a feed shipping is not evidence that it changed anyone's behaviour.
- The invocation figure is load context with no source row of its own in my write-up. It is not a usage metric and not a success metric.
- The event fix covers the confirmation path described above. It is not a platform-wide exactly-once guarantee, and explicit idempotency machinery exists in a minority of the backend's functions rather than all of them.
- A related piece of work — moving the notification step out of the comment and reaction path, where it accounted for most of the latency — is described in my own notes both as shipped and as still in flight. Because I cannot resolve that contradiction from the sources I have, it is left out of this page rather than claimed.
- This was team work. The parts I list under "My role" are mine; design, product scope and release approval were not.
Sources & available artifacts
What each item below actually is: my own account, a document, a dashboard snapshot, or an executed test. Step's code and internal tooling are not public, so most of this is my description of work you cannot open — which is exactly why it is labelled that way.
- My own accountMy account of the implementation and the event-handling fixNot public
Not independently verifiable from this site. The underlying repository and event handlers are Step's private code.
- Technical write-upMy own experience document for this work (§3.C, §3.E, §4, Appendices C, D, F)Not public
A detailed internal write-up I keep, inspected while writing this page. Not published: it contains internal paths and operational detail.
- Audit artifactThe Circles V2 acceptance matrix and the 2026-07-30 code auditNot public
The source of the 195 imported cases and the 108 / 47 / 155 audit figures. Both are internal artifacts.
Every figure on this page, with what it measures and what it does not prove (4)
195 acceptance test cases for Circles V2 were generated from the requirement documents and imported into the test-management tool.
- Verification
- Source inspected, not independently verified
- Source
- Step Experience Master Document §1.5 / §3.I / Appendix A6 (2026-09-11)
- Environment
- not-applicable
- What is measured
- A count of acceptance-matrix cases produced from the Circles V2 requirement documents by a generator script and imported into the test-management tool. Scoped to Circles V2 — not the Movement Feed, and not the whole test platform. Generated and imported, not hand-authored, and separate from the app's UI and unit test suites.
- Limits
- 195 imported cases is not 195 passing automated tests. No execution report for this set is presented here.
A static audit of Circles V2 compared the acceptance matrix against the code: 108 of 155 checks satisfied, 47 gaps recorded.
- Verification
- Source inspected, not independently verified
- Source
- Circles V2 code-vs-matrix audit, 2026-07-30, cited in the Step Experience Master Document Appendix F4 / §7 (2026-07-30)
- Environment
- not-applicable
- What is measured
- A static comparison of the acceptance matrix against the code as it stood on that date: 108 checks satisfied, 47 not, 155 total (~70% implemented at audit time). The 47 gaps fed a fix backlog. This is a code audit, not a test execution, and the 155 checks are a different artifact from the 195 imported cases.
- Limits
- A point-in-time audit. Nothing here claims the 47 gaps were all subsequently closed, and no audit result for a later date is presented.
Workouts imported from Apple Health were confirmed through a MODIFY event while the feed rule only fired on INSERT, so they never produced feed cards. The rule was widened and a scheduled reconciliation job with idempotent backfill was added.
- Verification
- Source inspected, not independently verified
- Source
- Step Experience Master Document §4 (story 6) / Appendix C2 (2026-09-11)
- Environment
- production
- What is measured
- A described defect in event handling: the event type that carried the confirmation was outside the set the feed rule matched on. The fix widened the matched set and added a reconciliation job that backfills missed cards idempotently.
- Limits
- The document's stated outcome — that the missed-card class was eliminated and the reconciliation job reports nothing left to backfill in steady state — appears in one place and is the one claim in this story with no source reference behind it. Treat it as my report, not as a measurement. The fix covers this confirmation path; it is not a platform-wide exactly-once guarantee.
The feed aggregator handles roughly 34,000 invocations per day.
- Verification
- Source inspected, not independently verified
- Source
- Step Experience Master Document §3.E / Appendix A (per-function volumes) (2026-09-11)
- Environment
- production
- What is measured
- Daily invocation count of the function that aggregates feed cards. Feature-scoped, unlike a backend-wide total.
- Limits
- Load context only. It describes how often the aggregator runs, not how many people used the feed, and not whether the feature succeeded. No specific source row backs this figure in the document; it is covered only by the general production snapshot.