Skip to content

feat(cloud): launch pricing guardrails — egress caps, ignore defaults, storage tiering - #114

Merged
ssowonny merged 1 commit into
mainfrom
worktree-whimsical-marinating-moore
Aug 3, 2026
Merged

feat(cloud): launch pricing guardrails — egress caps, ignore defaults, storage tiering#114
ssowonny merged 1 commit into
mainfrom
worktree-whimsical-marinating-moore

Conversation

@ssowonny

@ssowonny ssowonny commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

TL;DR

  • Pricing is now a single $19/user/mo Team tier plus contact-sales Enterprise, free tier at 3 seats. This is the OSS half: the seams and the cost controls.
  • Public share links can finally be capped — QuotaProvider gained CheckRead/RecordEgress, enforced on /s/* only so a sync can never be broken by a bill.
  • bdrive init warns before you sync a folder nobody meant to share, and the starter ignore list now keeps videos and archives out.
  • GCS lifecycle stops at Nearline on purpose: known gap — the Coldline/Archive ladder is priced against a read pattern the sync engine does not have (see below).
  • docs/launch-plan.md still said Cloud was waitlist-only. It isn't.

The read half of QuotaProvider

CheckWrite/CheckSeat/RecordUsage had no read-side counterpart, which
meant the managed side was structurally incapable of metering egress no
matter what a pricing page promised.

The asymmetry is deliberate:

Route CheckRead RecordEgress
/s/* public share links ✅ enforced
/store/* sync proxy ❌ never
viewer / history blob ❌ never

A share link is the only unauthenticated door to stored bytes, so it is the
only egress a plan can cap and the only bandwidth number worth publishing.
Refusing a device mid-sync surfaces as ErrForbidden, which the syncer reads
as "access is gone — pause and touch nothing" — a far worse outcome than an
over-quota bill.

UnlimitedQuota stays the OSS default, so self-hosters hit none of it.

countingWriter bills what actually reached the client: FileInfo.Size and
the journal's Size are both claims made before the write, so a reader who
abandons a download halfway is not charged for the whole file.

Oversized-folder warning

bdrive init now prints a warning past 1 GiB or 20k files and points at
bdrive scope. syncer.Measure sizes it through the real Filter and the
one walkFolder predicate, so a repo whose bulk is already ignored measures
as the few MB that really sync — the warning fires on the home directory
someone pointed at BearDrive, not on every ordinary checkout.

.git is deliberately not in starterIgnore: config.ReservedDirs
already excludes it at every depth in two packages, and listing it would
imply a switch that does not exist.

Storage tiering — and why it stops at Nearline

The obvious move is to age blobs down to Archive (~6% of Standard). Verified
current us-central1 pricing, and the break-even is entirely about read rate:

Class $/GB/mo Retrieval $/GB Beats the tier above while
Standard 0.020
Nearline 0.010 0.01 r < 1.0/mo
Coldline 0.004 0.02 r < 0.6/mo
Archive 0.0012 0.05 r < 0.09/mo

Old blobs are not cold here. A device syncing a project for the first
time downloads a blob for every put op in every peer journal — the entire
history, not just the current tree. Measured on a 10-version file whose
working tree is 1 KB: a fresh device pulls 10 KB. So blob read rate tracks
how often anyone adds a device, and a team adding roughly one device a month
makes Coldline a wash and Archive a straight bill increase.

The real lever is making a first sync fetch only current-state blobs. That
would cut onboarding egress and make the colder tiers safe. Until then the
ladder is betting on a read pattern we do not have.

Architecture changes

Both detail diagrams are updated on this branch.

architecture/webapp-server.md

QuotaProvider gained CheckRead(org, bytes) and RecordEgress(org, bytes);
countingWriter is new; ShareDB now depends on QuotaProvider, and
Server on countingWriter. Nothing was removed.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Server["<b>Server</b>"]
    ShareDB["<div style='text-align:left'><b>ShareDB</b><br/>shares.go — /s/&lt;token&gt;</div>"]
    QuotaProvider["<div style='text-align:left'><b>QuotaProvider</b> <i>(interface)</i><br/>+CheckWrite(org, bytes)<br/>+CheckSeat(org, members)<br/>+RecordUsage(org, bytes)<br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +CheckRead(org, bytes)</span><br/><span style='background:#22c55e55;padding:0 4px;border-radius:3px'>✅ +RecordEgress(org, bytes)</span></div>"]
    UnlimitedQuota["<b>UnlimitedQuota</b>"]
    reservations["<div style='text-align:left'><b>reservations</b><br/>reserve.go</div>"]
    countingWriter["<div style='text-align:left'><b>countingWriter</b><br/>quota.go<br/>+Write(p) n<br/>+n int64</div>"]
    Asym["The read half is asymmetric on purpose.<br/>CheckRead runs on /s/* and nowhere else —<br/>a share link is the only unauthenticated<br/>door to stored bytes.<br/>Refusing a device mid-sync reads as<br/>ErrForbidden = 'access is gone', so<br/>/store/* and the viewer only RecordEgress.<br/>Sync must never break over a bill."]
    Bill["Bills what actually reached the client:<br/>a reader who abandons a download<br/>is not charged for the whole file."]
    Server --- QuotaProvider
    Server --- ShareDB
    UnlimitedQuota -. implements .-> QuotaProvider
    reservations -- "CheckWrite(size + outstanding), RecordUsage on landing" --> QuotaProvider
    ShareDB -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ CheckRead before the stream, RecordEgress after</span>" --> QuotaProvider
    Server -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ every bytes-out route that bills</span>" --> countingWriter
    QuotaProvider -.- Asym
    countingWriter -.- Bill
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class countingWriter added
    class Asym,Bill noteBox
    linkStyle 4 stroke:#22c55e,stroke-width:2px
    linkStyle 5 stroke:#22c55e,stroke-width:2px
Loading

architecture/cli-sync.md

Measure is new — a third consumer of the single walkFolder predicate,
alongside scan and Explain. Nothing was removed.

✅ added · ❌ removed (strikethrough) · unmarked = unchanged

flowchart TB
    Session["<b>Session</b>"]
    Explain["<div style='text-align:left'><b>Explain</b><br/>+Explain(folder, include, accepted) two lists</div>"]
    Measure["<div style='text-align:left'><b>Measure</b><br/>walk.go<br/>+Measure(folder, include) files, bytes</div>"]
    walkFolder["<div style='text-align:left'><b>walkFolder</b><br/>+walkFolder(folder, filter, fn)<br/>verdict: vSync vSkipFile vDescend vPruneDir vNested</div>"]
    Filter["<div style='text-align:left'><b>Filter</b><br/>ignore.go</div>"]
    Only["walk.go — the ONLY copy of the sync predicate.<br/>scan, Explain and Measure all go through it,<br/>so what --explain reports and what init warns<br/>about cannot drift from what leaves."]
    Why["Sizes what a FIRST sync would upload, for the<br/>oversized-folder warning bdrive init prints.<br/>Filter-aware on purpose: a 40 GB repo whose bulk<br/>is already ignored measures as the few MB that<br/>really sync. Advice only — never fatal."]
    Session -- "scan" --> walkFolder
    Explain -- "same predicate" --> walkFolder
    Explain -- "own fresh instance" --> Filter
    walkFolder -- "SkipUp / PruneDir / addNestedMount" --> Filter
    Measure -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ same predicate</span>" --> walkFolder
    Measure -- "<span style='background:#22c55e55;padding:0 5px;border-radius:3px'>✅ own fresh instance</span>" --> Filter
    walkFolder -.- Only
    Measure -.- Why
    classDef added fill:#22c55e22,stroke:#22c55e,stroke-width:2px
    classDef noteBox fill:#88888822,stroke:#888888,stroke-dasharray:2 2
    class Measure added
    class Only,Why noteBox
    linkStyle 4 stroke:#22c55e,stroke-width:2px
    linkStyle 5 stroke:#22c55e,stroke-width:2px
Loading

Testing

go build ./..., go vet ./..., go test ./... all pass.

New: internal/syncer/measure_test.go proves Measure counts only what
really syncs and shrinks with bdrive scope. The existing QuotaProvider
test fakes now embed UnlimitedQuota, so the next widening of that interface
does not need a no-op added to nine files.

Both mermaid blocks in each diagram were parse-checked before commit.

🤖 Generated with Claude Code

https://claude.ai/code/session_019dHmCQSpn6WgkqFG62umK4

…, storage tiering

Pricing was re-cut to a single $19/user/mo Team tier plus contact-sales
Enterprise, with a 3-seat free tier. This is the open-source half of that:
the seams the managed hub needs, and the cost controls that make a
retained-forever version history affordable.

- QuotaProvider grows a read half: CheckRead(org, bytes) and
  RecordEgress(org, bytes). CheckRead is enforced on /s/* only — a public
  share link is the sole unauthenticated door to stored bytes, so it is the
  only egress a plan can cap. The sync proxy and viewer merely RecordEgress:
  refusing a device mid-cycle surfaces as ErrForbidden, which the syncer
  reads as "access is gone — pause and touch nothing", and sync must never
  break over a bill. UnlimitedQuota stays the OSS default, so a self-hoster
  hits none of this.

- countingWriter bills what actually reached the client. FileInfo.Size and
  the journal's Size are claims made before the write; an abandoned download
  must not be charged as a whole file.

- bdrive init warns when a folder would sync more than 1 GiB or 20k files,
  and says how to narrow it. syncer.Measure sizes that through the real
  filter and the one walkFolder predicate, so a repo whose bulk is already
  ignored stays quiet while the folder nobody meant to share does not.

- starterIgnore gains video/archive/disk-image globs and Library/. Every
  version is kept forever, so a big binary committed once is paid for
  forever on every device that ever syncs. .git stays out of the list: it is
  already excluded at every depth by config.ReservedDirs, and a rule for it
  would imply a switch that does not exist.

- deploy: a Nearline-at-30-days lifecycle rule, and the arithmetic for why
  it stops there. Coldline and Archive only pay off when an object is read
  less than about once a month, and a first sync pulls EVERY historical
  blob, not just the current tree — so blob read rate tracks device
  onboarding, not how often anyone opens an old version.

- docs/launch-plan.md said Cloud was waitlist-only and framed Product Hunt
  as an OSS launch whose goal was "not signups or revenue". Cloud is live
  with public pricing and no waitlist; the PH launch is the managed service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019dHmCQSpn6WgkqFG62umK4
@ssowonny
ssowonny merged commit e02bd53 into main Aug 3, 2026
2 checks passed
@ssowonny
ssowonny deleted the worktree-whimsical-marinating-moore branch August 3, 2026 10:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant