Skip to content

feat(gating): land the capability gate and reconcile it with G22-G25 - #83

Merged
Adron merged 2 commits into
devfrom
fix/account-status-gating
Sep 14, 2026
Merged

Adron merged 2 commits into
devfrom
fix/account-status-gating

Conversation

@Adron

@Adron Adron commented Sep 14, 2026

Copy link
Copy Markdown
Member

Summary

Merges origin/dev into fix/account-status-gating — a complete, test-bearing capability-gate implementation that had been committed but never pushed and never PR'd — and reconciles it with the three feature areas (G23 lists sharing, G24 documents tree, G25 orgs + org LinkedIn) built after it was written.

Closes #42, #40, #41 and #39.

Why this was urgent: nine TODO(#40)/TODO(#41) markers in already-merged dev code pointed at a CapabilityGate that existed only on this unpushed branch. DM send and media attach failed at the server with a bare 403 instead of a sentence, and document-creation gating read isSubscriber directly rather than through the shared vocabulary. All nine are retired here because each is genuinely satisfied.

The matrix

Feature goes from 3 cases to 11. The rule: creation is gated; managing, moving and leaving what you already have is free — a lapsed subscriber keeps existing content fully usable.

Surface Method Decision
Lists addWatcher 🔒 .sharingWithPeople
Lists watching · contributors · watcherCandidates ✅ free (reads)
Documents createDocument 🔒 .documentCreation
Documents moveDocument ✅ free — not creation
Orgs delete · leave · setMemberSuspended ✅ free — manage what you have
Orgs syncLinkedInPages · assignLinkedInPage 🔒 .crossPosting
Orgs disconnectLinkedIn ✅ free — never trap a connection
DM uploadImage 🔒 verified email

Two calls worth naming:

  • Org LinkedIn sync/assign is .crossPosting. These establish a publishing destination. Leaving them free would let an org route quietly create what the personal cross-post route refuses to — one product capability with two answers.
  • disconnectLinkedIn stays free on purpose. A user must be able to undo a connection precisely when their subscription has lapsed.

There is no listFolderCreation case. List folders are not returning to macOS (#49, owner decision), and a gate for a feature the client does not have is dead code that reads like a promise.

The 24-call-site trap

requireListManagement() guarded all 24 ListsService write methods on dev, including pure reads. canManageLists was permissive-by-default (?? true), so that was harmless — but tightening it without first removing the read call sites would have stopped free users reading their own lists.

The gate is now creation-only (1 call site), the reads are explicitly ungated, and GatingMatrixTests asserts both directions: a gated action must be refused before the HTTP call (asserting only that an error was thrown would pass even if the gate ran after the round-trip), and a free action must not be refused at all.

Three defects fixed on the way

  1. The staleness hole. EntitlementsService is built from user?.customerStatus, and SessionService.restore() was the only re-fetch path — launch and sign-in. Verifying an email in a browser left the Mac app gated until relaunch. The app now re-resolves the session on scenePhase == .active.

  2. send-verification-email was annotated .bearer on the strength of a 401 for an unauthenticated caller — which only ever proved anonymous fails. A live probe returns 401 under a valid Bearer token; the route really is x-auth-type: session. EmailVerification.swift was right, AuthEndpoint.swift was wrong; the builder now says so and stays unwired.

  3. bug(ai): AI copy tells subscribers to add an API key that no longer exists (G30) #39 — the AI copy told subscribers to "add your own AI provider key", a setting that does not exist. AI is included in the subscription, so an empty providers[] is a service-side outage. The copy no longer asks the user to fix something they cannot.

Conflict resolution notes

DocumentsService kept dev's entitlementsProvider name and its .free default over the branch's .subscriber — "never wrongly entitled" is the safer invariant. OrgService was standardised the same way, which is why eleven pre-existing tests now pass .subscriber explicitly: they were relying on a permissive default.

⚠️ ListsService still defaults .subscriber. Flagging rather than fixing silently — it has its own listManagementOverride mechanism and changing it would widen this PR further. Worth a follow-up to settle on one convention.

Verification

Full E2E gate, run in the worktree:

  • xcodebuild ... build → ** BUILD SUCCEEDED **
  • swift test --package-path Packages/InterlinedKit → Executed 483 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedDomain → Executed 1012 tests, with 0 failures (0 unexpected)
  • swift test --package-path Packages/InterlinedPersistence → Executed 140 tests, with 0 failures (0 unexpected)
  • xcodebuild ... test CODE_SIGNING_ALLOWED=NO → Executed 968 tests, with 0 failures (0 unexpected) · ** TEST SUCCEEDED **
  • Decision 0003 (anchored grep) → zero hits
  • grep TODO(#40)\|TODO(#41) → zero — all nine retired

Baseline before this PR was Kit 478 / Domain 946 / Persistence 140 / App 954.

New tests: GatingMatrixTests (15 cases across the matrix, both directions) plus a BDD quartet for the DM attachment gate in DMThreadViewModelTests.

Sequencing note

This branches from dev, not from the unmerged docs/phase2-consolidation. If that PR merges first, expect a small work-consolidation.md conflict — both touch the status section.

🤖 Generated with Claude Code

https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9

Adron and others added 2 commits September 9, 2026 09:57
…d tier

Closes #42, #40, #41 — built as one model rather than three patches, because
all three answer the same question: will the server refuse this, and why?

`CapabilityGate` composes the three mechanisms and evaluates them hardest-first
(status → verification → tier), so the user is told the reason they can act on.
A restricted subscriber is told they are under review, not to upgrade; a new,
unverified, free account is told to verify, which is both the cheaper fix and
the documented fastest way off probation.

#42 — accountStatus was entirely unmodelled. Now decoded off GET /api/user
(verified live), narrowed to the five documented cases with an `.unknown`
escape hatch, and surfaced as a timeline banner for new/restricted/suspended.
The OpenAPI schema types the field as a bare string with no enum, so unknown
values fail *open*: a server-side rename cannot brick a paying user's app.

#40 — `Feature` grows from 3 modelled features to the documented 12, and
`canManageLists` loses its M3 permissive default. That default was masking a
worse bug: all 21 ListsService write methods routed through it, including pure
reads (`myLists`, `rows`, `watchers`) and row edits. Flipping the default alone
would have stopped free users reading their own lists. The gate now sits on
`create` only, and the case names (`.listCreation`, not `.lists`) encode the
create-only rule so no call site can gate an edit, a row insert, or a revoke by
accident. Documents and organizations gain the same create-only gate.

#41 — the composer, media picker, and Settings now consult the gate up front
instead of failing at publish. `POST /api/auth/send-verification-email` is
`x-auth-type: session` and rejects this Bearer client, so the resend action
deep-links to web Settings rather than shipping a button that 401s silently.
The 10-minute cooldown is modelled as local advisory state; the server owns it.

Verification (full E2E gate, all observed):
- xcodebuild build                    ** BUILD SUCCEEDED **
- xcodebuild test (App)               762 tests, 0 failures — ** TEST SUCCEEDED **
- swift test InterlinedKit            403 tests, 0 failures
- swift test InterlinedDomain         823 tests, 0 failures
- swift test InterlinedPersistence    135 tests, 0 failures
- Decision 0003 grep                  zero real imports
- Contract tests (live, read-only)    5 tests, 0 failures

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSyDpagUre475rMMxtXgLE
Merges origin/dev into fix/account-status-gating and resolves the four
conflicts, then applies the settled entitlement matrix to the surfaces
(G23 lists sharing, G24 documents tree, G25 orgs + org LinkedIn) that were
built after this branch was written.

Closes #42 (accountStatus unmodelled), #40 (entitlement matrix drift),
#41 (email-verification gate) and #39 (AI availability copy).

WHY THIS COULD NOT WAIT

Nine TODO(#40)/TODO(#41) markers in already-merged dev code pointed at a
CapabilityGate that existed only on this unpushed branch. DM send and media
attach failed at the server with a bare 403 instead of a sentence, and
document-creation gating read isSubscriber directly rather than through the
shared vocabulary. All nine are now retired because each is genuinely
satisfied, not because the comment was deleted.

THE MATRIX

Feature goes from 3 cases to 11. The rule: creation is gated; managing,
moving and leaving what you already have is free -- a lapsed subscriber
keeps existing content fully usable.

Gated: media, scheduled posts, cross-posting, list/document/template/org
creation, sharing-with-people (including ListsService.addWatcher), email
invites, share links, AI, and org LinkedIn syncLinkedInPages /
assignLinkedInPage.

Free: every read, moveDocument, org delete/leave/setMemberSuspended, and
disconnectLinkedIn.

Two calls are worth naming. Org LinkedIn sync/assign is gated as
.crossPosting because it establishes a publishing destination -- leaving it
free would let an org route quietly create what the personal cross-post
route refuses to. disconnectLinkedIn stays free on purpose: a user must be
able to undo a connection precisely when their subscription has lapsed.

There is deliberately no listFolderCreation case. List folders are not
returning to macOS (#49, owner decision 2026-09-14), and a gate for a
feature the client does not have is dead code that reads like a promise.

THE 24-CALL-SITE TRAP

requireListManagement() guarded all 24 ListsService write methods on dev,
including pure reads. canManageLists was permissive-by-default (?? true), so
that was harmless -- but tightening it without first removing the read call
sites would have stopped free users reading their own lists. The gate is now
creation-only (1 call site), watching/contributors/watcherCandidates are
explicitly ungated, and GatingMatrixTests asserts both directions: a gated
action must be refused BEFORE the HTTP call, and a free action must not be
refused at all.

THREE DEFECTS FIXED ON THE WAY

1. The staleness hole. EntitlementsService is built from user?.customerStatus
   and SessionService.restore() was the ONLY re-fetch path -- launch and
   sign-in. Verifying an email in a browser left the Mac app gated until
   relaunch. The app now re-resolves the session on scenePhase == .active.

2. send-verification-email was annotated .bearer on the strength of a 401 for
   an UNAUTHENTICATED caller, which only ever proved anonymous fails. A live
   probe returns 401 under a valid Bearer token: the route really is
   x-auth-type: session. EmailVerification.swift was right and AuthEndpoint
   was wrong; the builder now says so and stays unwired.

3. #39 -- the AI copy told subscribers to "add your own AI provider key",
   a setting that does not exist. AI is included in the subscription, so an
   empty providers[] is a service-side outage. The copy no longer asks the
   user to fix something they cannot.

CONFLICT RESOLUTION NOTES

DocumentsService kept dev's entitlementsProvider name and its .free default
over the branch's .subscriber -- "never wrongly entitled" is the safer
invariant. OrgService was standardised the same way, which is why eleven
pre-existing tests now pass .subscriber explicitly: they were relying on a
permissive default. ListsService still defaults .subscriber; that
inconsistency is flagged in the PR rather than fixed silently here.

Gate: BUILD SUCCEEDED; Kit 483, Domain 1012, Persistence 140, App 968, all
0 failures; TEST SUCCEEDED; Decision 0003 zero hits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9
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