Repository navigation
feat(gating): land the capability gate and reconcile it with G22-G25 - #83
Merged
Merged
Conversation
…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
This was referenced Sep 15, 2026
Closed
Closed
Merged
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Merges
origin/devintofix/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-mergeddevcode pointed at aCapabilityGatethat 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 readisSubscriberdirectly rather than through the shared vocabulary. All nine are retired here because each is genuinely satisfied.The matrix
Featuregoes 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.addWatcher.sharingWithPeoplewatching·contributors·watcherCandidatescreateDocument.documentCreationmoveDocumentdelete·leave·setMemberSuspendedsyncLinkedInPages·assignLinkedInPage.crossPostingdisconnectLinkedInuploadImageTwo calls worth naming:
.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.disconnectLinkedInstays free on purpose. A user must be able to undo a connection precisely when their subscription has lapsed.There is no
listFolderCreationcase. 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 24ListsServicewrite methods ondev, including pure reads.canManageListswas 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
GatingMatrixTestsasserts 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
The staleness hole.
EntitlementsServiceis built fromuser?.customerStatus, andSessionService.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 onscenePhase == .active.send-verification-emailwas annotated.beareron 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 isx-auth-type: session.EmailVerification.swiftwas right,AuthEndpoint.swiftwas wrong; the builder now says so and stays unwired.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
DocumentsServicekept dev'sentitlementsProvidername and its.freedefault over the branch's.subscriber— "never wrongly entitled" is the safer invariant.OrgServicewas standardised the same way, which is why eleven pre-existing tests now pass.subscriberexplicitly: they were relying on a permissive default.ListsServicestill defaults.subscriber. Flagging rather than fixing silently — it has its ownlistManagementOverridemechanism 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 **grep TODO(#40)\|TODO(#41)→ zero — all nine retiredBaseline 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 inDMThreadViewModelTests.Sequencing note
This branches from
dev, not from the unmergeddocs/phase2-consolidation. If that PR merges first, expect a smallwork-consolidation.mdconflict — both touch the status section.🤖 Generated with Claude Code
https://claude.ai/code/session_016gSWb3scYobtxLJioV1qF9