feat: company standing gate (generic run-blocking primitive) - #256
Open
stubbi wants to merge 38 commits into
Open
feat: company standing gate (generic run-blocking primitive)#256stubbi wants to merge 38 commits into
stubbi wants to merge 38 commits into
Conversation
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ings validator barrel exports Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s and derivePublicFeatureFlags Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… only) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
GET /instance/settings, /general, /experimental move from assertBoardOrgAccess to assertCanManageInstanceSettings. The UI migrates to /cli-auth/me capabilities.features in the same PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t_exposed 403 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…yStandings) on GET /cli-auth/me Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR-1 made GET /api/instance/settings/experimental instance-admin-only, which broke `paperclip cloud push` for non-admin board users: they'd get 403 "Instance admin access required" instead of the intended "Cloud sync is disabled" message. Switch the cloud-sync gate to read capabilities.features.enableCloudSync from GET /api/cli-auth/me, which is available to any authenticated board user and derives the same flag server-side. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d secrets routes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ites Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pabilities hooks Adds capabilities: BoardCapabilities to CurrentBoardAccess (from GET /cli-auth/me), and useFeatures/useBoardCapabilities hooks sharing a single react-query cache entry (queryKeys.access.currentBoardAccess). useFeatures selects capabilities.features for the settings-surface policy migration off instance-admin-only /instance/settings* reads. Adds queryKeys.instance.visibilitySettings (used in Task 14) and the buildCurrentBoardAccess test fixture builder. Updates IssueDetail.test.tsx call sites that construct CurrentBoardAccess literals directly, since capabilities is now a required field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lities.features Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… redirects
Gate CompanySettingsSidebar and CompanySettingsNav company entries on
useBoardCapabilities().exposedSurfaces, restrict the Instance settings
section/tabs to isInstanceAdmin, and move the per-user Profile entry out
of the instance-only block. Add SurfaceGuard to redirect
company/settings/{members,invites,secrets} navigation misses to
/company/settings when the loaded capabilities say the surface is
hidden (degrades closed while capabilities are loading/errored, since
the server remains authoritative). Rewrite CompanySettingsSidebar.test.tsx
onto the Task 11/12 access mock and add member/admin/degrade-closed/
plugin-override cases; extend CompanySettingsNav.test.tsx with a
surface-filtering case; add SurfaceGuard.test.tsx; adjust Layout.test.tsx's
mobile-selector test to mock instance-admin + cloud-sync access so it still
exercises the full tab set.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add error handling to CompanySurfaceVisibilityCard following the established pattern from InstanceGeneralSettings. Failed PATCH operations now display an error banner with the error message, rather than silently reverting the button. - Add actionError state to track failed saves - Set error message in onError handler - Clear error on successful save - Display destructive error banner when actionError is set - Add failing test that asserts error banner appears on save failure Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t, test names) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nd company.standing.write capability Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nup helpers Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n company.standing.write Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e/company-disable Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing is blocked Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PR-1's capabilities builder shipped a hardcoded `{}` stub for
companyStandings pending PR-3. Wire it to the real
companyStandingService.getEffectiveStandings(accessSnapshot.companyIds)
so the CLI sees each requested company's effective standing (every
requested company is seeded, active by default, blocked/grace when a
plugin has written a row).
Also add the companyStandingService export to the services/index.js
mocks in invite-accept-existing-member, invite-list-route, and
openclaw-invite-prompt-route route tests, since accessRoutes() now
constructs the service unconditionally and those suites were
otherwise-passing before this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tandings empty Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implement hardening for actionUrl handling in company standing: - Server-side validation: reject javascript:/data: URLs, allow app-relative paths (/) and http/https URLs only - UI: app-relative URLs keep same-tab navigation; absolute URLs render with target="_blank" rel="noreferrer" per repo pattern (ExternalObjectPill) - Tests: validate rejected/accepted URL schemes; verify link attributes Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ity routes in OpenAPI spec Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend service mocks in cli-auth-routes, bootstrap-claim-routes,
company-user-directory-route, invite-create-email, and invite-create-route
test suites with companyStandingService mock (getEffectiveStandings resolving {}).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y gates Standing enforcement was admission-only (heartbeat.wakeup): a blocked company could still have its queued runs claimed via resumeQueuedRuns or its scheduled retries promoted, since claimQueuedRun and evaluateScheduledRetryGate only checked budget. Add the same companyStandingService(db).getEffectiveStanding(companyId) check beside each site's existing budget check, mirroring how each site already disposes of a budget-blocked run/gate (cancelRunInternal / gate refusal). `grace` never blocks, matching the admission gate. Covered by new TDD suites: claimQueuedRun via resumeQueuedRuns (blocked disposes with the standing reason, grace claims normally) and evaluateScheduledRetryGate via scheduleBoundedRetry (blocked refuses with company_standing_blocked, grace schedules normally). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- getEffectiveStandings: order the row query by updatedAt desc, then
pluginId, so two rows tied on severity resolve deterministically to
the newest row's reason instead of depending on DB scan order.
- setStanding: the actionUrl-scheme catch checked
err.message.includes("Invalid standing"), but the thrown message is
"Invalid actionUrl scheme...", so that branch never matched. Split
the URL-parse failure from the scheme-check failure so the
invalid-scheme badRequest propagates once, with no dead re-create.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
Thinking Path
Linked Issues or Issue Description
Fixes #259
No pre-existing public issue tracks this; describing in-PR per the feature-request template.
Problem or motivation
There is no generic mechanism for a plugin to stop a company from starting new agent work. Billing needs exactly this (block runs on a lapsed subscription, without core knowing about money), and future compliance/quota plugins will need the same shape. Building it ad hoc per plugin risks inconsistent enforcement points and, worse, no shared cleanup guarantee — a buggy or removed governance plugin could leave a company permanently blocked.
Proposed solution
New core table
company_standing(PKcompany_id+plugin_id,status: active|grace|blocked,reason,message,action_url,updated_at) — row-per-plugin so plugins cannot clobber each other's holds. Effective standing per company = most severe row; no rows ⇒active(fail-safe default). New capabilitycompany.standing.writeand host-service methodsctx.companies.setStanding(companyId, {...})/clearStanding(companyId), always scoped to the calling plugin. Enforcement is one check reused at every run-admission path: the enqueue gate, the claim gate, and the scheduled-retry gate — all consulting effective standing alongside the existing budget hard-stop.gracenever blocks (UI-only warning); only persistedblockedrows stop work. Cleanup is automatic: uninstalling a plugin, instance-disabling it, or a company disabling it deletes that plugin's standing rows. UI: banners and company-switcher badges ride the PR-1capabilities.companyStandingspayload — no bespoke fetch/banner component.Alternatives considered
A single
active/blockedboolean per company (nograce, no per-plugin rows) was rejected — it would let a second plugin's clear accidentally unblock a company another plugin meant to keep blocked, and gives no room for a "you're about to be blocked" warning state that billing's grace period needs. Enforcing only at enqueue (skipping claim/retry) was rejected because a run already claimed before a company goesblockedcould otherwise complete a retry loop indefinitely; enforcing at all three admission points closes that gap.Roadmap alignment
Checked
ROADMAP.md— generic core primitive, upstreamable, not overlapping planned core work. Full design atdocs/superpowers/specs/2026-07-18-settings-visibility-and-plugin-enablement-design.md§5.EffectiveStandingtype is reused from PR-1's capabilities payload shape.What Changed
company_standingmigration (fkcompany_id, fkplugin_id,statusenum,reason,message,action_urlnullable,updated_at); effective standing = most-severe-row merge, deterministic tiebreak added (ORDER BYon severity ties — closed a nondeterminism found in review).company.standing.writecapability (flagged sensitive on the plugin install screen).setStanding/clearStanding, always scoped to the calling plugin; cleanup wired into plugin uninstall, instance-disable, and company-disable paths (registry inline delete, intentionally not shared with the general clear helper — see Risks).claimQueuedRun, andevaluateScheduledRetryGate— all now check effective standing alongside the existing budget block; typed errorcompany_blockedcarriesmessage+actionUrl.blocked— only new-run admission is affected.grace→ warning + action link,blocked→ error banner) and company-switcher badges, both fed bycapabilities.companyStandings(PR-1 payload).actionUrlvalidation hardened (rejects non-relative/unsafe URLs before persisting).Verification
cd server && pnpm typecheck— 0 errors (branch was sanctioned red mid-stack for one task pending a follow-up fix; green again before merge).blockedrows from two plugins.blocked⇒ typedcompany_blockederror at all three admission points (enqueue, claim, retry);grace⇒ runs proceed unaffected.active/grace/blocked.feat/company-standing-gate, basefeat/settings-surface-policy; final whole-branch review returned "with fixes" (claim/retry gates initially missed the standing check, caught in review) — fix wave landed and re-verified.Risks
feat/settings-surface-policy(PR-1), and is a sibling offeat/company-plugin-enablement(PR-2) — both branch from the same PR-1 head. Diff againstmainwill include PR-1 until it merges; review the PR-1-vs-PR-3 diff range.active— a crashed or buggy governance plugin can never lock a company out; only an explicit, successfully-persistedblockedrow stops work. This was a deliberate design constraint, not an incidental property.claimQueuedRun(~line 10334) andevaluateScheduledRetryGate(~line 8841) both consult effective standing.plugin-registry.tsinlines the scoped standing-delete instead of calling the sharedcompanyStandingService.clearStanding(a brief-mandated duplication to avoid a cross-module cycle) — tracked as a DRY refactor candidate, not a correctness issue.company_standingmigration will need renumbering against upstream's drizzle migration chain at cherry-pick/upstream time (fork's snapshot chain is currently forked from upstream's).Model Used
Claude (Anthropic), Sonnet 5 (model id
claude-sonnet-5) driving Claude Code's subagent-driven SDD workflow — spec-driven task briefs, extended multi-step tool use, and an independent code-review pass per task, plus a dedicated whole-branch final review before merge.Screenshots
Standing banner — grace vs blocked
Company switcher — standing badges
Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template#NNN/github.com/paperclipai/paperclipURLs)docs/...,fix/...) and contains no internal Paperclip ticket id or instance-derived details