Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions .github/workflows/docs-drift.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Docs Drift

# Docs-vs-API drift gate, docs-homed copy.
#
# The published docs are what a partner builds against before they ever call us.
# When the backend renames a field, retires an endpoint or changes an event
# name, nothing noticed that the docs still described the old behaviour, and the
# drift got found by a customer whose integration broke. This makes the
# mechanical half of that impossible rather than something a reader might catch.
#
# This copy fires on a DOCS pull request and checks it against fuel-backend@main:
# it catches a page that describes an endpoint, field or event the API does not
# have. A mirror workflow of the same name lives in fuel-backend and fires on a
# BACKEND pull request, checking it against docs@main, which catches the other
# and more dangerous direction: an API change that leaves the docs stale. Neither
# repo can path-trigger on the other's commits, so both copies are required; the
# same reason calculator-crossval.yml is mirrored between fuel-backend and
# saturday, and the sibling-clone mechanics below are lifted from it.
#
# The checker and the baseline live HERE, in the docs repo, in one copy. The
# fuel-backend mirror clones this repo and runs this same script, so the two
# sides cannot disagree about what drift means.
#
# WHAT A GREEN RUN DOES NOT PROVE: that the numbers are right. Every check here
# is structural. A page stating a default of 60 g/hr against code that says 90
# passes cleanly, and that is the class of error that prompted this gate. The
# structural half is what is mechanically decidable today; see the script header
# for what closing the rest would take.
#
# ORDERING HAZARD, stated because it will happen. A rename that spans both repos
# cannot make both sides green at the same instant: the docs cannot name a field
# that fuel-backend@main does not have yet, and the backend cannot remove one the
# docs still cite. Removals sequence cleanly, docs first, because deleting a
# mention never introduces drift. A rename does not. Both copies are advisory
# until marked required in branch protection, so the human lands both and main
# goes green; do not "solve" it by widening the baseline, which is what the
# shrink-only job exists to prevent.
#
# Runs on Saturday's self-hosted runner on alex-desktop, off the GitHub Actions
# quota. Standard library python, no Mintlify build, no node install: ~0.2s.

on:
pull_request:
branches: [main]
paths:
- '**.mdx'
- 'api-drift-baseline.txt'
- 'scripts/check-docs-drift.py'
- '.github/workflows/docs-drift.yml'
push:
branches: [main]
paths:
- '**.mdx'
- 'api-drift-baseline.txt'
- 'scripts/check-docs-drift.py'
workflow_dispatch:

concurrency:
group: docs-drift-${{ github.ref }}
cancel-in-progress: true

jobs:
drift:
runs-on: self-hosted
timeout-minutes: 10
steps:
# Both repos as siblings INSIDE the workspace, the layout
# calculator-crossval.yml uses, so nothing is written outside it.
- name: Checkout docs (this PR)
uses: actions/checkout@v5
with:
path: docs

# Self-hosted runner, so the box's own authenticated SSH clones the
# private repo and no cross-repo PAT secret is needed. Shallow, because
# only the working tree is read.
- name: Checkout fuel-backend@main (sibling)
run: |
set -euo pipefail
rm -rf fuel-backend
git clone --depth 1 --branch main \
git@github.com:SaturdayInc/fuel-backend.git fuel-backend
echo "fuel-backend at: $(git -C fuel-backend rev-parse --short HEAD)"

- name: Check the docs still describe the live API
run: |
python3 docs/scripts/check-docs-drift.py \
--docs docs --backend fuel-backend --list

# The baseline records drift that predates the gate so unrelated PRs are not
# blocked by it. That only holds while it can shrink and not grow: a baseline
# a PR may append to is an off switch, and the next real regression gets
# written into it instead of reported. Removing entries is the point and is
# always allowed.
baseline-shrink-only:
if: github.event_name == 'pull_request'
runs-on: self-hosted
timeout-minutes: 10
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Reject additions to the drift baseline
run: |
set -euo pipefail
base='${{ github.event.pull_request.base.sha }}'
git fetch --no-tags --depth=1 origin "$base" 2>/dev/null || true
added=$(git diff "$base"...HEAD -- api-drift-baseline.txt \
| grep -E '^\+[^+#]' | wc -l)
removed=$(git diff "$base"...HEAD -- api-drift-baseline.txt \
| grep -E '^-[^-#]' | wc -l)
echo "baseline entries removed by this PR: $removed"
echo "baseline entries added by this PR: $added"
if [ "$added" -gt 0 ]; then
echo
echo "This PR adds $added entr(y/ies) to api-drift-baseline.txt."
echo "The baseline is shrink-only. It holds the drift that already"
echo "existed when the gate landed; a new entry hides a new problem."
echo "Fix the drift the checker named, or if the checker is wrong"
echo "about it, say so in review and change the checker instead."
git diff "$base"...HEAD -- api-drift-baseline.txt | grep -E '^\+[^+#]' || true
exit 1
fi
echo "Baseline did not grow."
4 changes: 4 additions & 0 deletions .mintignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@
drafts/
*.draft.mdx
Trigger rebuild

# CI tooling, not published content
scripts/
api-drift-baseline.txt
19 changes: 19 additions & 0 deletions api-drift-baseline.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Docs drift baseline, read by scripts/check-docs-drift.py.
#
# Every line suppresses one finding. SHRINK-ONLY: fix the underlying problem and
# delete the line. A line that no longer matches a real finding FAILS the gate,
# because a suppression that suppresses nothing is a lie about the state of the
# docs and would silently absorb the next real regression. Adding a line fails
# too, for the same reason: a list that can grow is not a baseline.
#
# Two kinds of entry live here and each says which it is:
# debt real drift, too expensive to fix in the PR that found it
# limit the checker cannot tell this apart from drift, and the docs are right
#
# Format: kind<TAB>identity<TAB>why

# documented field that exists nowhere in the API
field cramp limit: a symptom map KEY in "symptoms": { "cramp": 1 }, not a schema field. The checker reads every key in a json fence and cannot see that this map is open-ended
field headers limit: a key in the MCP CLIENT config example, which is a third-party config file that happens to be json, not a Saturday API payload
field saturday limit: the MCP server nickname a reader chooses inside their own mcpServers config, same fence as above
field transport limit: MCP client transport setting, same fence as above
2 changes: 1 addition & 1 deletion guides/freemium-model.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ Give Saturday a `return_url` (https or deep link) at partner onboarding and the

Some paying Saturday athletes never touch your subscribe CTA — they subscribed **inside the Saturday app** (Apple/Google in-app purchase) or on saturday.fit before joining your platform. Saturday links these automatically: when the `email` you set on an athlete exactly matches a Saturday account's email (case-insensitive; no fuzzy matching), the records are linked and the athlete's API responses unlock.

- **Opt in by supplying emails.** Set `email` on your athletes (`POST`/`PATCH /v1/athletes`). Matching runs when you write an athlete email, when a Saturday subscription activates, and in a nightly sweep.
- **Opt in by supplying emails.** Set `email` on your athletes (`POST /v1/athletes` and `PATCH /v1/athletes/{athlete_id}`). Matching runs when you write an athlete email, when a Saturday subscription activates, and in a nightly sweep.
- **You get the same webhook.** When a match links an actively paying account, `subscription.created` fires with `source: "email_match"` — handle it exactly like a checkout unlock.
- **Ambiguity never auto-links.** Multiple athletes sharing an email, or a conflict with an existing link, goes to Saturday-side human review instead.
- **No revenue share on matched links.** These subscriptions weren't driven through your platform (many predate it), so they don't appear on your revenue statement.
Expand Down
2 changes: 1 addition & 1 deletion guides/organizations.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -274,4 +274,4 @@ PATCH /v1/organizations/{org_id}

## Finding an organization's athletes

Athletes reference their organization through the `org_id` field on the athlete record (`POST` and `PATCH /v1/athletes`, see [Organization offers](#organization-offers-negotiated-discounts) above). There is no endpoint that lists an organization's athletes. To build a team roster view, keep your own athlete-to-org mapping as you assert `org_id`, or for licensed teams use the seat-subscription [license list](#assign-licenses-to-athletes): `GET .../licenses` returns exactly the athletes the org covers.
Athletes reference their organization through the `org_id` field on the athlete record (`POST /v1/athletes` and `PATCH /v1/athletes/{athlete_id}`, see [Organization offers](#organization-offers-negotiated-discounts) above). There is no endpoint that lists an organization's athletes. To build a team roster view, keep your own athlete-to-org mapping as you assert `org_id`, or for licensed teams use the seat-subscription [license list](#assign-licenses-to-athletes): `GET .../licenses` returns exactly the athletes the org covers.
Loading
Loading