diff --git a/TASKS.md b/TASKS.md index 6699f6e..3996ee0 100644 --- a/TASKS.md +++ b/TASKS.md @@ -125,7 +125,7 @@ Main code areas: - общий Community runtime/UI/API surface - `crates/crank-core` - `apps/mcp-server` -- `/home/github-ops/crank/docs/modular_decomposition.xml` +- `docs/modular_decomposition.xml` Implementation slices: 1. выполнять общие изменения сначала в `crank-community`; diff --git a/docs/modular_decomposition.xml b/docs/modular_decomposition.xml new file mode 100644 index 0000000..6ee4772 --- /dev/null +++ b/docs/modular_decomposition.xml @@ -0,0 +1,1661 @@ + + + + + + + + + + The plan is strong as an architectural direction, especially in its + seam catalog and open-core discipline. However, it should not be + executed as-is yet. Several assumptions are already stale relative + to the current repository split, and some proposed crate boundaries + are probably too granular for the next implementation step. + + + + + The host/plugin principle is correct: crank-community should know + only public traits and contracts, while enterprise/cloud supply + private implementations. + + + The seam catalog is the strongest part of the document. In + particular: + ProtocolAdapterRegistry, MachineCredentialVerifier, + MachineTokenIssuer, PolicyEngine, AuditSink, CapabilityProfile + and RegistryExtension are concrete and useful extension points. + + + The migration model for private database tables via additive + extensions is correct. It is better than shipping enterprise/cloud + schema inside the community baseline. + + + The document correctly treats commercial security and governance + as server-side functionality, not as “hidden code in public + sources”. + + + + + + Principle A7 is already stale + + A7 says the monorepo /home/github-ops/crank remains the source + of truth for community until the end of Phase 4. This no longer + matches reality. We already work in three separate repositories: + /home/github-ops/crank/community, + /home/github-ops/crank/enterprise, + /home/github-ops/crank/cloud. + The document should be updated to reflect the current split + stage; otherwise later decisions will optimize for a state that + no longer exists. + + + + + Community topology and protocol boundary are not fully aligned + + The topology says crank-community is REST-only, but it still + keeps crank-proto as a community workspace member. If + crank-proto exists only to support gRPC/protobuf workflows, it + weakens the claimed community boundary. If it is intentionally + retained as a shared schema/tooling seam, that justification + should be written explicitly. + + + + + The plan introduces new crates too early + + Proposed crates such as crank-community-auth, + crank-community-admin and crank-community-mcp may be valid later, + but creating them immediately risks optimizing for modularity in + the abstract rather than for stable boundaries. A modular + architecture does not require maximal crate count. We should + first stabilize seams and only then promote modules into new + crates where the ownership and lifecycle justify it. + + + + + UI overlay model may over-fragment the product surface + + Principle A5 prefers build-time overlays for premium UI. That is + useful for clearly separate enterprise/cloud panels, but it + should not replace the existing edition-aware base UI entirely. + A hybrid model is likely safer: + community ships the shared UI scaffold, + server-side capability gating remains mandatory, + overlays are used only for truly private panels rather than for + rewiring common pages. + + + + + Dependency strategy is correct for releases but underspecified for daily development + + Depending on crank-community via git tags is reasonable for + release boundaries. It is not enough as a day-to-day development + workflow. The document should explicitly choose one local + strategy: + path dependencies, + git submodules, + or a defined sync branch workflow. + Without this, the repos may diverge operationally even if the + architecture is correct. + + + + + Not all seams are equally urgent + + The seam catalog is comprehensive, but some seams are clearly + Phase-1/Phase-2 material, while others belong much later. + First-wave seams should likely be limited to: + ProtocolAdapterRegistry, + MachineCredentialVerifier, + MachineTokenIssuer, + CapabilityProfile, + PolicyEngine, + AuditSink, + RegistryExtension. + Metering, BillingHook and TenantController are valid, but they + should not block the first modularization pass. + + + + + + + Should crank-community remain strictly REST-only in both runtime + and workspace membership, or is there an intentional shared + protocol/tooling subset (for example crank-proto) that is allowed + to stay public as a neutral seam? + + + Do we want modularity first at the module boundary level, and only + then crate extraction, or do we intentionally want to create new + crates as part of the first pass? + + + For UI, do we agree on a hybrid model: + shared edition-aware base UI plus build-time overlays only for + truly private sections, + instead of trying to implement premium UI solely as overlays? + + + What is the exact local development model between the three repos: + path-based composition, git submodules, or release-tag sync only? + This needs to be chosen explicitly before large modular moves. + + + Which seams are approved for the first implementation wave, and + which are intentionally deferred to avoid over-engineering the + migration? + + + + + + Update the document so it reflects the current split state rather + than the pre-split monorepo state. + + + Freeze the first-wave seam set: + ProtocolAdapterRegistry, + MachineCredentialVerifier, + MachineTokenIssuer, + CapabilityProfile, + PolicyEngine, + AuditSink, + RegistryExtension. + + + Keep new functionality in modules first. Create new crates only + when the seam is stable and independent enough to justify a + separate crate. + + + Resolve the community/public boundary around crank-proto and any + remaining protocol-related public crates before moving more code. + + + Choose and document one concrete local dependency workflow across + community, enterprise and cloud. + + + + + + + + + + All six issues raised by codex are accepted. The plan is rewritten + below accordingly. Decisions on the five open questions are recorded + explicitly in the architecture section as principles A7..A10. + + + + Community is strict REST-only across runtime AND workspace + membership. crank-proto moves to crank-enterprise. Admin-api + descriptor upload routes (upload_proto_descriptor, + upload_descriptor_set, upload_wsdl_descriptor, + upload_xsd_descriptor) and list endpoints (list_grpc_services, + list_soap_services) move with it. + + + Modules first, crates only when there is a real cross-binary + consumer. The plan now creates only two community library crates + (crank-community-auth, crank-community-mcp) instead of three, and + explicitly keeps AdminService inside apps/admin-api. + + + Hybrid UI. Shared edition-aware base UI stays in community with + server-side capability gating. Build-time overlays are used only + for truly private panels: SSO config, TOTP enrollment, audit-trail + viewer, billing-panel, plus the wizard cards for non-REST + protocols. + + + Daily development uses git submodules. crank-enterprise embeds + crank-community at vendor/crank-community/, crank-cloud embeds both + community and enterprise the same way. Cargo dependencies inside + private repos are path = "vendor/.../crates/...". Release pipelines + pin the submodule to a specific tag and produce signed images. + crates.io is not used for community crates. + + + Three waves: + Wave 1 (Phase 0, mandatory before any extraction): + ProtocolAdapterRegistry, MachineCredentialVerifier, + MachineTokenIssuer, CapabilityProfile, PolicyEngine, + AuditSink, RegistryExtension, IdentityProvider (login_password + only — SSO/TOTP method group deferred). + Wave 2 (Phase 2.x, just before each is consumed): + IdentityProvider SSO/TOTP extension (start_sso, complete_sso, + enroll_totp, verify_totp, disable_totp), + CacheBackendFactory. + Wave 3 (Phase 3, cloud-only): + MeteringSink, BillingHook, TenantController. + + + + Architecture rewritten to reflect 4 working copies on disk. There + is no Phase 4 "physical split" step anymore — repositories already + exist as separate clones. The corresponding milestone is now Phase + 4 "Consolidation: retire top-level baseline and make community/ + subfolder the canonical community working copy". + + + crank-proto removed from community topology. Migration of + crank-proto and admin-api descriptor routes documented in Phase 1 + (task 1.1) and Phase 2 (task 2.7). + + + crank-community-admin crate removed from the plan. service.rs stays + inside apps/admin-api. Only crank-community-auth and + crank-community-mcp are introduced, both justified by a concrete + enterprise-side consumer. + + + Phase 5 rewritten to the hybrid model. Slot registry is limited + to: settings.sso_panel, settings.totp_panel, settings.audit_panel, + settings.billing_panel, wizard.protocol_cards.{graphql,grpc,soap, + websocket}. All other UI stays as shared edition-aware code. + + + Daily development workflow recorded as principle A9 (git submodule) + with concrete folder layout and Cargo.toml shape. A separate + <local-dev-workflow> section captures the exact developer commands. + + + Seam catalog now tags every seam with its wave. Phase 0 implements + Wave 1 only. Phase 2 introduces Wave 2 seams. Phase 3 introduces + Wave 3 seams. + + + + + + + + + crank-community is the host. It owns all public domain types, the + runtime, the admin-api binary, the mcp-server binary, and the + community UI. It compiles and runs on its own without any private + crate. + + + Every commercial concern is reached only through a trait defined in + crank-community. The host knows the trait, not the implementation. + Plug-ins ship the implementation and are injected at binary build + time via builders. + + + Modules are the primary unit of decomposition. A new crate is + created only when there is a concrete external consumer that needs + the boundary. The default location for new code is a new module in + an existing crate, not a new crate. + + + Each repository is its own cargo workspace. crank-enterprise and + crank-cloud do not depend on community via crates.io. They depend + on community sources made available locally through a git + submodule pinned to a release tag. + + + UI is hybrid. Shared edition-aware base UI stays in community with + server-side capability gating through `/api/capabilities`. Truly + private panels (SSO config, TOTP enrollment, audit-trail viewer, + billing-panel, wizard cards for non-REST protocols) are not + present in community sources at all and are added at build time by + an overlay package shipped from the corresponding private repo. + + + Database schema lives in crank-community only for community-only + tables (workspaces, users, agents, operations, secrets, logs, + usage, static api keys). Enterprise/cloud-only tables + (audit_events, sso_providers, totp_factors, machine_tokens, + metering_events, billing_records, tenants) live in plug-in crates + and are registered through RegistryExtension. Migrations are + additive and applied only when the binary linked the corresponding + extension. + + + Current state on disk (2026-05-11): + /home/github-ops/crank/ -> top-level baseline, origin: bsodfather/crank + /home/github-ops/crank/community/ -> origin: crank-community + /home/github-ops/crank/enterprise/ -> origin: crank-enterprise + /home/github-ops/crank/cloud/ -> origin: crank-cloud + All four are independent git working copies. crank-community has + already been published as the public REST-only baseline. The + top-level baseline is legacy and will be retired in Phase 4. There + is no separate "physical split" milestone, because the split is + already done on the filesystem. + + + Community is strict REST-only in BOTH runtime and workspace + membership. The community workspace MUST NOT contain crank-proto, + crank-adapter-graphql, crank-adapter-grpc, crank-adapter-soap or + crank-adapter-websocket. The community admin-api MUST NOT expose + protocol descriptor routes for non-REST protocols. Protocol + tooling for premium protocols lives in crank-enterprise. + + + Local development across the three repositories uses git submodules: + crank-enterprise/vendor/crank-community/ (submodule, pinned to tag) + crank-cloud/vendor/crank-community/ (submodule, pinned to tag) + crank-cloud/vendor/crank-enterprise/ (submodule, pinned to tag) + Cargo dependencies inside private workspaces resolve community + and enterprise crates through `path = "vendor/.../crates/..."`, + never through git URLs or crates.io. Release pipelines bump the + submodule pointer and produce signed images; they do not change + the dependency style. + + + Crate creation rule: a new crate is justified if and only if at + least one of the following is true: + (a) the code is consumed by a binary in a different repository + (cross-repo consumer); + (b) the code has a different release cadence or ownership; + (c) the code defines a stable public API surface that must + outlive its current consumer. + Wanting "more modularity" is not, by itself, a justification. + + + Version compatibility is explicit: + - every crank-enterprise release pins one specific crank-community tag; + - every crank-cloud release pins one specific crank-enterprise tag; + - crank-cloud MUST NOT bump vendor/crank-community independently in a way that bypasses the version expected by vendor/crank-enterprise unless a separate compatibility verification task says so; + - private CI must fail if a vendor submodule points to an arbitrary branch tip instead of a tagged commit or approved release-candidate ref. + + + The top-level /home/github-ops/crank working copy is legacy. New + product code, refactors and behavioral changes are allowed only in: + /home/github-ops/crank/community/ + /home/github-ops/crank/enterprise/ + /home/github-ops/crank/cloud/ + The top-level baseline remains only as a temporary reference/doc + container until Phase 4 retires it completely. + + + + + + + + + Concrete developer setup so the three repositories stay coherent + day to day. This is the implementation of principle A9. + + + + Standard cargo workspace. No vendoring. apps/ui stays outside the + Cargo workspace and is built separately. Developers clone, run + `just check`, `just test`, `just fmt`. Releases are git-tagged. + + + + git clone git@github.com:bsodfather/crank-enterprise.git + git submodule add -b main git@github.com:bsodfather/crank-community.git vendor/crank-community + git submodule update --init --recursive + cargo build --workspace + + crank-core = { path = "vendor/crank-community/crates/crank-core" } + crank-runtime = { path = "vendor/crank-community/crates/crank-runtime" } + crank-registry = { path = "vendor/crank-community/crates/crank-registry" } + crank-community-auth = { path = "vendor/crank-community/crates/crank-community-auth" } + crank-community-mcp = { path = "vendor/crank-community/crates/crank-community-mcp" } + crank-adapter-rest = { path = "vendor/crank-community/crates/crank-adapter-rest" } + + + + + git clone git@github.com:bsodfather/crank-cloud.git + git submodule add -b main git@github.com:bsodfather/crank-community.git vendor/crank-community + git submodule add -b main git@github.com:bsodfather/crank-enterprise.git vendor/crank-enterprise + git submodule update --init --recursive + cargo build --workspace + + Same as crank-enterprise for community deps, plus: + crank-enterprise-auth = { path = "vendor/crank-enterprise/crates/crank-enterprise-auth" } + crank-enterprise-tokens = { path = "vendor/crank-enterprise/crates/crank-enterprise-tokens" } + crank-enterprise-protocols= { path = "vendor/crank-enterprise/crates/crank-enterprise-protocols" } + crank-proto = { path = "vendor/crank-enterprise/crates/crank-proto" } + crank-adapter-graphql = { path = "vendor/crank-enterprise/crates/crank-adapter-graphql" } + crank-adapter-grpc = { path = "vendor/crank-enterprise/crates/crank-adapter-grpc" } + crank-adapter-soap = { path = "vendor/crank-enterprise/crates/crank-adapter-soap" } + crank-adapter-websocket = { path = "vendor/crank-enterprise/crates/crank-adapter-websocket" } + + + + + When community releases vX.Y.Z: + cd vendor/crank-community + git fetch --tags + git checkout vX.Y.Z + cd ../.. + git add vendor/crank-community + git commit -m "deps: bump community to vX.Y.Z" + cargo update -w + run full test suite + Same shape for crank-cloud bumping crank-enterprise. + + + + A private repo MUST NOT edit files inside its vendor/ submodule. If a change is needed in community, open a PR in crank-community first, tag a release, then bump the submodule. + Submodules track tags, not branches. The `main` branch in submodule add commands above is for the initial fetch; subsequent updates use detached HEAD at specific tags. + CI in private repos runs `git submodule update --init --recursive` before any cargo command. + + + + + + + + + + crates/crank-core + crates/crank-schema + crates/crank-mapping + crates/crank-registry + crates/crank-runtime + crates/crank-adapter-rest + crates/crank-community-auth + crates/crank-community-mcp + apps/admin-api + apps/mcp-server + + + apps/ui (separate frontend app; not a Cargo workspace member) + + + crank-proto, crank-adapter-graphql, crank-adapter-grpc, + crank-adapter-soap, crank-adapter-websocket. None of these + belong in the public REST-only host. + + + + Justified: consumed by crank-enterprise-auth, which wraps + PasswordIdentityProvider in TotpEnforcer. Cross-repo consumer. + + + Justified: consumed by enterprise-mcp-server, which reuses + jsonrpc / catalog / session and replaces only the credential + verifier. Cross-repo consumer. + + + + + + + crates/crank-proto (moved from community) + crates/crank-adapter-graphql (moved from community) + crates/crank-adapter-grpc (moved from community) + crates/crank-adapter-soap (moved from community) + crates/crank-adapter-websocket (moved from community) + crates/crank-enterprise-auth (SSO + TOTP + recovery codes) + crates/crank-enterprise-tokens (short-lived + one-time + verifier) + crates/crank-enterprise-rbac (owner/admin/operator/viewer policy) + crates/crank-enterprise-audit (audit_events sink + extension) + crates/crank-enterprise-protocols (adapter registry helper + EnterpriseCapabilityProfile) + crates/crank-enterprise-descriptors (proto/wsdl/xsd upload service for admin-api) + apps/enterprise-admin-api (wires community admin-api scaffold + enterprise crates + descriptor routes) + apps/enterprise-mcp-server (wires community-mcp + enterprise verifier + enterprise adapter registry) + apps/enterprise-ui-overlay (build-time UI overlay package) + + vendor/crank-community/ (submodule, pinned to a community tag) + + + + + crates/crank-cloud-tenants (HostedTenantController) + crates/crank-cloud-metering (UsageMeteringSink) + crates/crank-cloud-billing (StripeBillingHook or alt) + crates/crank-cloud-control-plane (ManagedCacheBackendFactory + cloud admin routes) + apps/cloud-admin-api + apps/cloud-mcp-server + apps/cloud-control-plane + + + vendor/crank-community/ (submodule) + vendor/crank-enterprise/ (submodule) + + + + + + + + + + + + + Replace the hard-coded fields on RuntimeExecutor with a registry of adapters keyed by Protocol. + + fn protocol(&self) -> Protocol; + async fn invoke_unary(&self, prepared: &PreparedRequest, ctx: &RuntimeRequestContext) -> Result<AdapterResponse, RuntimeError>; + async fn invoke_window(&self, prepared: &PreparedRequest, ctx: &RuntimeRequestContext) -> Result<WindowExecutionResult, RuntimeError> { /* default: RuntimeError::Unsupported */ } + async fn open_session(&self, prepared: &PreparedRequest, ctx: &RuntimeRequestContext) -> Result<StreamSession, RuntimeError> { /* default: RuntimeError::Unsupported */ } + fn supports_mode(&self, mode: ExecutionMode) -> bool; + + crank-adapter-rest exports `pub struct RestAdapter` and `impl ProtocolAdapter for RestAdapter`. Community RuntimeExecutorBuilder registers only this adapter. + crank-adapter-{graphql,grpc,soap,websocket} each `impl ProtocolAdapter`. crank-enterprise-protocols::register_enterprise_adapters(builder) adds all four to a builder. + + + + Already exists at apps/mcp-server/src/auth.rs:11-23. Lift into crank-core unchanged; the StaticAgentKeyVerifier stays in community as the default. + + async fn verify_bearer_token(&self, workspace_slug: &str, agent_slug: &str, token: &str) -> Result<Option<VerifiedMachineCredential>, MachineCredentialVerifierError>; + + StaticAgentKeyVerifier (renamed from CommunityMachineCredentialVerifier) — accepts only platform_api_key matches, returns MachineAccessMode::StaticAgentKey + Standard security_level. + EnterpriseMachineCredentialVerifier in crank-enterprise-tokens. + + + + Replace direct calls from admin-api routes to service-internal token stubs with a trait. Community returns NotSupportedInEdition; Enterprise issues real tokens. + + async fn issue_short_lived(&self, req: IssueAgentTokenRequest, actor: &TokenIssuerActor) -> Result<IssuedAgentTokenResponse, TokenIssuerError>; + async fn issue_one_time(&self, req: IssueOneTimeAgentTokenRequest, actor: &TokenIssuerActor) -> Result<IssuedAgentTokenResponse, TokenIssuerError>; + + NoMachineTokenIssuer — always returns TokenIssuerError::NotSupportedInEdition. Replaces the inline 403 body in apps/admin-api/src/service.rs::issue_agent_token / issue_one_time_agent_token. + EnterpriseMachineTokenIssuer (lifted from enterprise working copy: enterprise/apps/admin-api/src/service.rs around `issue_short_lived_token_from_agent_key`, `issue_short_lived_token_from_refresh_token`, `issue_one_time_agent_token`). + + TokenIssuerActor { user_id: UserId, workspace_id: WorkspaceId, role: MembershipRole } + TokenIssuerError { NotSupportedInEdition, InvalidGrant(String), AgentKeyUnknown, OperationNotStrict, OperationNotPublishedForAgent, RegistryFailure(String), ReplayGuardFailure(String) } + + + + + Pluggable identity backend for /api/auth/login. First wave covers only password login; SSO and TOTP methods are added in S4-v2 (Wave 2). + + fn id(&self) -> &str; + fn kind(&self) -> IdentityProviderKind; + async fn login_password(&self, payload: LoginPayload) -> Result<LoginOutcome, IdentityError>; + + + IdentityProviderKind { Password } + LoginOutcome { Authenticated(AuthenticatedSession) } + IdentityError { NotSupportedForProvider, BadCredentials, AccountDisabled, Internal(String) } + + PasswordIdentityProvider (in crank-community-auth) — current Argon2 + session cookie flow. + LoginOutcome is intentionally an open enum so PendingTwoFactor can be added in S4-v2 without breaking match exhaustiveness — every match site in community will keep `_` arm at the end. + + + + Widen the IdentityProvider trait when enterprise SSO/TOTP code physically lands in crank-enterprise-auth. + + async fn start_sso(&self, workspace: &WorkspaceId, return_to: Option<String>) -> Result<StartSsoOutcome, IdentityError> { /* default: Err(NotSupportedForProvider) */ } + async fn complete_sso(&self, params: SsoCallbackParams) -> Result<LoginOutcome, IdentityError> { /* default */ } + async fn enroll_totp(&self, user: &UserId) -> Result<TotpEnrollment, IdentityError> { /* default */ } + async fn verify_totp(&self, user: &UserId, code: &str) -> Result<(), IdentityError> { /* default */ } + async fn disable_totp(&self, user: &UserId) -> Result<(), IdentityError> { /* default */ } + + + LoginOutcome += PendingTwoFactor(PendingTwoFactorTicket) + + OidcIdentityProvider (per-workspace) + TotpEnforcer wrapper. Both in crank-enterprise-auth. + + + + Replace ad-hoc role checks scattered across admin-api routes with one PolicyEngine. + + fn check(&self, actor: &SessionActor, action: PolicyAction, scope: PolicyScope) -> PolicyDecision; + + + PolicyAction { ReadWorkspace, ManageMembers, ManageInvitations, ManageSecrets, ManageAuthProfiles, ManageOperations, RunTests, ManageAgents, RotateAgentKeys, IssueMachineTokens, ReadAuditTrail, ManageSsoProviders, ManageTotpForUser, ExportWorkspace, DeleteWorkspace } + PolicyScope { Workspace(WorkspaceId), Operation(OperationId), Agent(AgentId), Self_ } + PolicyDecision { Allow | Deny { reason: &'static str } } + + OwnerOnlyPolicyEngine — only MembershipRole::Owner is allowed for write actions; reads allowed to any member. + RbacPolicyEngine — owner/admin/operator/viewer matrix (lift from enterprise admin-api). + + + + Every state-changing admin route emits an AuditEvent. Community discards; Enterprise persists. + + async fn record(&self, event: AuditEvent) -> Result<(), AuditError>; + async fn list(&self, q: AuditQuery) -> Result<Page<AuditEvent>, AuditError> { /* default: empty page */ } + async fn get(&self, id: &AuditEventId) -> Result<Option<AuditEvent>, AuditError> { /* default: None */ } + + + AuditEvent { id, occurred_at, actor: AuditActor, action: String, target: AuditTarget, payload: serde_json::Value, source_ip, user_agent } + AuditActor { user_id, email, session_id } + AuditTarget { workspace_id, kind: AuditTargetKind, id: String } + + NoopAuditSink. + PostgresAuditSink in crank-enterprise-audit. + + + + Replace the hard-coded body of AdminService::get_capabilities with a trait. + + fn capabilities(&self) -> EditionCapabilities; + + CommunityCapabilityProfile — current AdminService::get_capabilities body (REST / Standard / StaticAgentKey / 1,1,1 limits). + EnterpriseCapabilityProfile in crank-enterprise-protocols. + CloudCapabilityProfile in crank-cloud-control-plane. + + + + Allow plug-in crates to register their own SQL migrations without touching crank-registry. Migrations are idempotent and tracked in a separate __crank_ext_migrations table. + + fn name(&self) -> &str; + fn migrations(&self) -> &[ExtensionMigration]; + + + ExtensionMigration { version: u32, sql: &'static str } + Tracking table: CREATE TABLE IF NOT EXISTS __crank_ext_migrations (extension_name TEXT NOT NULL, version INT NOT NULL, applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (extension_name, version)); + + No extensions registered. + AuditEventsExtension, SsoProvidersExtension, TotpFactorsExtension, MachineTokensExtension. + TenantsExtension, MeteringExtension, BillingExtension. + + + + Promote RuntimeCacheStores construction to a trait factory. The seam lives in runtime, not core, because it returns runtime cache store types and must not create a crank-core -> crank-runtime dependency cycle. + + async fn build(&self, config: &RuntimeCacheConfig) -> Result<RuntimeCacheStores, RuntimeCacheStoreInitError>; + + BuiltinCacheBackendFactory — wraps existing crank-runtime::cache code. + ManagedCacheBackendFactory in crank-cloud-control-plane. + + + + Runtime emits a MeteringEvent on every invocation completion. Community drops it; Cloud forwards to the billing pipeline. + + async fn record(&self, event: MeteringEvent); + + NoopMeteringSink. + UsageMeteringSink in crank-cloud-metering. + + + + Reacts to MeteringSink rollups. Pure Cloud concern. + + async fn on_rollup(&self, rollup: UsageRollup) -> Result<(), BillingError>; + async fn check_billing_gate(&self, tenant: &TenantId) -> Result<BillingGate, BillingError>; + + NoopBillingHook (Allow). + StripeBillingHook in crank-cloud-billing. + + + + Multi-tenant context resolution. + + fn resolve_tenant(&self, request: &TenantResolutionContext) -> Result<TenantId, TenancyError>; + async fn list_workspaces_for_tenant(&self, tenant: &TenantId) -> Result<Vec<WorkspaceId>, TenancyError>; + + SingleTenantController (returns TenantId::new("default")). + HostedTenantController in crank-cloud-tenants. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Introduce Wave 1 plug-in traits in crank-core / crank-registry plus a Builder pattern for the runtime and admin service, with default community implementations colocated so the workspace continues to build and pass `just check` / `just test` exactly as today. Zero behavior change. + + + Create crank-core::ext module skeleton + + pub mod protocol; + pub mod auth; + pub mod access; + pub mod audit; + pub mod capability; + + Add `pub mod ext;` at the end of the module list. + cargo check --workspace + + + + Lift MachineCredentialVerifier into crank-core (S2) + Trait already exists at apps/mcp-server/src/auth.rs:11-23. Move it byte-identical apart from imports. + + use async_trait::async_trait; + use crate::{MachineAccessMode, OperationSecurityLevel, PlatformApiKeyScope}; + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct VerifiedMachineCredential { + pub machine_access_mode: MachineAccessMode, + pub max_security_level: OperationSecurityLevel, + pub scopes: Vec<PlatformApiKeyScope>, + } + + #[derive(Debug, thiserror::Error)] + #[error("machine credential verifier failed")] + pub struct MachineCredentialVerifierError; + + #[async_trait] + pub trait MachineCredentialVerifier: Send + Sync { + async fn verify_bearer_token( + &self, + workspace_slug: &str, + agent_slug: &str, + token: &str, + ) -> Result<Option<VerifiedMachineCredential>, MachineCredentialVerifierError>; + } + + pub type SharedMachineCredentialVerifier = std::sync::Arc<dyn MachineCredentialVerifier>; + + + Remove the in-file trait definition. Replace with: + pub use crank_core::ext::auth::{MachineCredentialVerifier, MachineCredentialVerifierError, SharedMachineCredentialVerifier, VerifiedMachineCredential}; + Rename `CommunityMachineCredentialVerifier` to `StaticAgentKeyVerifier`. Add `pub use StaticAgentKeyVerifier as CommunityMachineCredentialVerifier;` for backward compat during this phase. + + cargo check --workspace; cargo test -p mcp-server --no-run + + + + Define MachineTokenIssuer (S3) and NoMachineTokenIssuer + + pub struct TokenIssuerActor { pub user_id: UserId, pub workspace_id: WorkspaceId, pub role: MembershipRole } + + #[derive(Debug, thiserror::Error)] + pub enum TokenIssuerError { + #[error("machine tokens are not available in this edition")] NotSupportedInEdition, + #[error("invalid grant: {0}")] InvalidGrant(String), + #[error("agent key is unknown")] AgentKeyUnknown, + #[error("operation is not strict")] OperationNotStrict, + #[error("operation is not published for agent")] OperationNotPublishedForAgent, + #[error("registry failure: {0}")] RegistryFailure(String), + #[error("replay guard failure: {0}")] ReplayGuardFailure(String), + } + + #[async_trait] + pub trait MachineTokenIssuer: Send + Sync { + async fn issue_short_lived(&self, req: IssueAgentTokenRequest, actor: &TokenIssuerActor) -> Result<IssuedAgentTokenResponse, TokenIssuerError>; + async fn issue_one_time(&self, req: IssueOneTimeAgentTokenRequest, actor: &TokenIssuerActor) -> Result<IssuedAgentTokenResponse, TokenIssuerError>; + } + + pub type SharedMachineTokenIssuer = std::sync::Arc<dyn MachineTokenIssuer>; + + pub struct NoMachineTokenIssuer; + + #[async_trait] + impl MachineTokenIssuer for NoMachineTokenIssuer { + async fn issue_short_lived(&self, _: IssueAgentTokenRequest, _: &TokenIssuerActor) -> Result<IssuedAgentTokenResponse, TokenIssuerError> { Err(TokenIssuerError::NotSupportedInEdition) } + async fn issue_one_time(&self, _: IssueOneTimeAgentTokenRequest, _: &TokenIssuerActor) -> Result<IssuedAgentTokenResponse, TokenIssuerError> { Err(TokenIssuerError::NotSupportedInEdition) } + } + + cargo check --workspace + + + + Define IdentityProvider Wave 1 (S4-v1) and PasswordIdentityProvider scaffold + + pub enum IdentityProviderKind { Password } + + #[derive(Debug, thiserror::Error)] + pub enum IdentityError { + #[error("not supported for this provider")] NotSupportedForProvider, + #[error("bad credentials")] BadCredentials, + #[error("account disabled")] AccountDisabled, + #[error("internal: {0}")] Internal(String), + } + + pub struct LoginPayload { pub email: String, pub password: String } + // open enum so Wave 2 can add PendingTwoFactor without breaking matches in community + pub enum LoginOutcome { Authenticated(AuthenticatedSession) } + + #[async_trait] + pub trait IdentityProvider: Send + Sync { + fn id(&self) -> &str; + fn kind(&self) -> IdentityProviderKind; + async fn login_password(&self, payload: LoginPayload) -> Result<LoginOutcome, IdentityError>; + } + + pub type SharedIdentityProvider = std::sync::Arc<dyn IdentityProvider>; + + AuthenticatedSession type lives in crank-core for now. If lifting it into core creates a cycle, declare a forwarder type and resolve via `impl From<...> for AuthenticatedSession` inside admin-api. Concrete PasswordIdentityProvider lands in crank-community-auth in task 1.4. + cargo check --workspace + + + + Define PolicyEngine (S5) and OwnerOnlyPolicyEngine + + enums + struct SessionActor + trait PolicyEngine per S5. + Default impl OwnerOnlyPolicyEngine: + - Owner: Allow on every action + - Anyone else: Allow on Read* actions; Deny on everything else. + + cargo check --workspace + + + + Define AuditSink (S6) and NoopAuditSink + + Types AuditEventId, AuditActor, AuditTargetKind, AuditTarget, AuditEvent, AuditQuery, AuditError. + Trait AuditSink with default `list`/`get` returning empty. + Default impl NoopAuditSink. + + cargo check --workspace + + + + Define CapabilityProfile (S11) and CommunityCapabilityProfile + + Trait CapabilityProfile { fn capabilities(&self) -> EditionCapabilities }. + Pub struct CommunityCapabilityProfile. + Impl returns exactly the same body currently in apps/admin-api/src/service.rs::get_capabilities (lines around 1570-1582). + + cargo check --workspace + + + + Define ProtocolAdapter (S1) and AdapterRegistry + + Trait ProtocolAdapter (per S1). + Pub struct AdapterRegistry with empty(), register(self, adapter), get(protocol), protocols(). + Type alias SharedProtocolAdapter = Arc<dyn ProtocolAdapter>. + + cargo check --workspace + + + + Implement ProtocolAdapter for crank-adapter-rest + + impl crank_core::ext::protocol::ProtocolAdapter for RestAdapter { + fn protocol(&self) -> crank_core::Protocol { crank_core::Protocol::Rest } + fn supports_mode(&self, mode: crank_core::ExecutionMode) -> bool { matches!(mode, ExecutionMode::Unary | ExecutionMode::Window) } + async fn invoke_unary(...) { /* wraps existing send_request -> AdapterResponse */ } + async fn invoke_window(...) { /* wraps existing window method */ } + } + + Confirm `crank-core = { path = "../crank-core" }` exists. + Adapter impls for graphql/grpc/soap/websocket land in Phase 2 — when those crates are physically moved into crank-enterprise. + cargo check --workspace + + + + RuntimeExecutor switches to AdapterRegistry + RuntimeExecutor currently holds one field per adapter behind #[cfg(feature = "...")] (crates/crank-runtime/src/executor.rs:33-44). Replace with a single AdapterRegistry. Selection in dispatch_invoke matches by Protocol enum. + + - Remove cfg-gated adapter fields. Replace with `adapters: AdapterRegistry`. + - Remove all `#[cfg(feature = "protocol-...")]` adapter imports at the top. + - In invocation methods (execute_unary, execute_window, open_session): look up adapter by `target.protocol()` via `self.adapters.get(...)` and call the trait method; return `RuntimeError::Unsupported(Protocol, ExecutionMode)` if missing or mode unsupported. + + + Remove all `crank-adapter-*` dependencies except `crank-adapter-rest = { path = "../crank-adapter-rest" }` (used by the community_default helper in task 0.11). + Remove the `[features]` section blocks `protocol-graphql/grpc/soap/websocket`. Keep `default = []`. + + cargo check -p crank-runtime; cargo test -p crank-runtime --no-run + + + + Add RuntimeExecutorBuilder + community_default() + + pub struct RuntimeExecutorBuilder { + limits: RuntimeLimits, + adapters: AdapterRegistry, + response_cache: Option<Arc<dyn ResponseCacheStore>>, + } + impl RuntimeExecutorBuilder { + pub fn new() -> Self + pub fn with_limits(mut self, limits: RuntimeLimits) -> Self + pub fn register_adapter(mut self, adapter: SharedProtocolAdapter) -> Self + pub fn with_response_cache(mut self, store: Arc<dyn ResponseCacheStore>) -> Self + pub fn build(self) -> RuntimeExecutor + } + pub fn community_default() -> RuntimeExecutorBuilder { + RuntimeExecutorBuilder::new().register_adapter(Arc::new(crank_adapter_rest::RestAdapter::new())) + } + + Re-export RuntimeExecutorBuilder and community_default. + cargo check --workspace + + + + Wire community_default() in community binaries + + Replace + let runtime = RuntimeExecutor::with_limits(runtime_limits).with_response_cache_store(cache_stores.response.clone()); + with + let runtime = crank_runtime::community_default() + .with_limits(runtime_limits) + .with_response_cache(cache_stores.response.clone()) + .build(); + + Same replacement. + just test + + + + RegistryExtension (S12) in crank-registry + + pub struct ExtensionMigration { pub version: u32, pub sql: &'static str } + pub trait RegistryExtension: Send + Sync { + fn name(&self) -> &str; + fn migrations(&self) -> &[ExtensionMigration]; + } + pub async fn apply_extension_migrations(pool: &PgPool, extensions: &[Arc<dyn RegistryExtension>]) -> Result<(), RegistryError> { + // ensure __crank_ext_migrations exists + // for each extension, for each migration not yet applied, run SQL inside a transaction + insert tracking row + } + + `pub mod ext;` and re-export. + cargo test -p crank-registry + + + + AdminServiceBuilder with seam slots + + Replace direct constructor with builder: + pub struct AdminServiceBuilder { + registry: PostgresRegistry, + storage_root: PathBuf, + auth_settings: AuthSettings, + secret_crypto: SecretCrypto, + runtime: RuntimeExecutor, + identity_provider: Option<Arc<dyn IdentityProvider>>, + policy_engine: Option<Arc<dyn PolicyEngine>>, + audit_sink: Option<Arc<dyn AuditSink>>, + token_issuer: Option<Arc<dyn MachineTokenIssuer>>, + capability_profile: Option<Arc<dyn CapabilityProfile>>, + } + impl AdminServiceBuilder { + pub fn new(registry, storage_root, auth_settings, secret_crypto, runtime) -> Self + pub fn with_identity_provider(mut self, p: Arc<dyn IdentityProvider>) -> Self + pub fn with_policy_engine(mut self, p: Arc<dyn PolicyEngine>) -> Self + pub fn with_audit_sink(mut self, s: Arc<dyn AuditSink>) -> Self + pub fn with_token_issuer(mut self, i: Arc<dyn MachineTokenIssuer>) -> Self + pub fn with_capability_profile(mut self, p: Arc<dyn CapabilityProfile>) -> Self + pub fn build(self) -> AdminService { + // None -> community defaults: OwnerOnlyPolicyEngine, NoopAuditSink, NoMachineTokenIssuer, CommunityCapabilityProfile. + // identity_provider has no default here — main.rs wires PasswordIdentityProvider (after task 1.4 lifts it). + } + } + AdminService gains pub(crate) Arc fields for each seam. + + Use AdminServiceBuilder. + cargo test -p admin-api --no-run + + + + Route handlers delegate to seam traits + + Replace `state.service.get_capabilities().await` with a call that goes through `state.service.capability_profile().capabilities()`. + + + Call `state.service.token_issuer().issue_short_lived(...)`. Map TokenIssuerError::NotSupportedInEdition into ApiError::Forbidden with body `{ "edition": <current>, "machine_access_mode": "short_lived_token", "grant_type": ..., "upgrade_required": true }` so the wire response matches today. + + + Delete bodies of `issue_agent_token` and `issue_one_time_agent_token`; either route around them or leave thin wrappers that call self.token_issuer. + + + Every write handler calls `state.service.policy_engine().check(...)` and bails on Deny BEFORE doing work. Audit emit goes after successful mutation. + + cargo test -p admin-api + + + + Phase 0 verification gate + + - just fmt; just check; just test all green. + - admin-api: GET /api/capabilities returns the community payload. + - admin-api: POST /api/agent-tokens returns 403 with `upgrade_required: true`. + - mcp-server smoke initialize with platform_api_key succeeds. + - cargo metadata --no-deps shows no `protocol-*` features on crank-runtime. + + + + + + Make community strict REST-only (per A8) and extract the two justified library crates. + + + Remove non-REST adapters and crank-proto from community workspace + + + + + + Remove the corresponding `[workspace] members` entries. + Deleting locally; the crates simultaneously appear in the crank-enterprise workspace as part of task 2.1. Until task 2.1 lands they may live in a feature branch in crank-enterprise that is not yet on main. + cargo check --workspace; cargo test --workspace --no-run + + + + Strip non-REST descriptor routes from community admin-api + + Remove handlers: upload_proto_descriptor, upload_descriptor_set, list_grpc_services, list_soap_services, upload_wsdl_descriptor, upload_xsd_descriptor. + + Remove the matching `.route(...)` lines and their imports. + + Remove the AdminService methods backing those routes (list_grpc_services, list_soap_services, upload_proto_descriptor, etc). Move any tests for these methods OUT of community (will be reintroduced in task 2.7 inside crank-enterprise-descriptors). + + Remove protocol cards / wizard branches for graphql, grpc, soap, websocket. Community wizard becomes REST-only. + Remove SOAP / GraphQL / gRPC / WebSocket protocol cards (keep only REST). UI strings for these protocols removed from apps/ui/js/i18n.js. + cargo test -p admin-api; npm --prefix apps/ui run build + + + + Drop dormant premium UI strings + Remove the `login.coming_soon` note and `login.sso_short` button entirely. + + Remove keys (en + ru mirrors): login.sso_short, login.coming_soon, settings.security.capability_title_enterprise, settings.notifications.capability_title_enterprise, wizard.step1.soap_tagline. + + Rebuild via `node apps/ui/scripts/build.js`. + npm --prefix apps/ui run build; npm --prefix apps/ui run e2e -- --grep "REST" + + + + Create crank-community-auth crate + Justified by R-Q2/A10: TotpEnforcer in enterprise wraps PasswordIdentityProvider — cross-repo consumer exists. + + name = "crank-community-auth" + Deps: crank-core, crank-registry, argon2, axum-extra (cookie), base64, rand, time, sha2, uuid, async-trait, thiserror, tracing. + + + pub mod hashing; // hash_password / verify_password (lifted from apps/admin-api/src/auth.rs) + pub mod session_cookie; // session_cookie / cleared_session_cookie / extract_session_token / SessionCookie / SESSION_COOKIE_NAME (lifted) + pub mod password_provider;// pub struct PasswordIdentityProvider { registry: PostgresRegistry, settings: AuthSettings }; impl IdentityProvider + + + Delete moved code; replace with `pub use crank_community_auth::{hashing::*, session_cookie::*, password_provider::PasswordIdentityProvider};`. + Keep BootstrapAdminConfig, AuthSettings, AuthenticatedSession, require_session, require_workspace_session here (axum middleware territory). + + Add crates/crank-community-auth to `[workspace] members`. + Add `crank-community-auth = { path = "../../crates/crank-community-auth" }`. + + let identity = Arc::new(PasswordIdentityProvider::new(registry.clone(), auth_settings.clone())); + let service = AdminServiceBuilder::new(...).with_identity_provider(identity).build(); + + cargo test -p crank-community-auth; cargo test -p admin-api + + + + Create crank-community-mcp crate + Justified: enterprise-mcp-server reuses jsonrpc + catalog + session, swaps only the verifier. + + Deps: crank-core, crank-registry, crank-runtime, crank-schema, async-trait, axum, base64, futures-util, serde, serde_json, sha2, sqlx, thiserror, time, tokio, tracing, uuid. + + + pub mod catalog; + pub mod jsonrpc; + pub mod session; + pub mod auth; // re-exports MachineCredentialVerifier + StaticAgentKeyVerifier + pub mod build_app; // pub fn build_app(...) -> axum::Router; same signature as today + + + Move catalog.rs, jsonrpc.rs, session.rs, auth.rs into crank-community-mcp. + apps/mcp-server/src/main.rs keeps env parsing + DI wiring and calls `crank_community_mcp::build_app(...)`. + + Add crates/crank-community-mcp to workspace members. + cargo test -p crank-community-mcp; cargo test -p mcp-server + + + + Replace `test = false` ballast + Audit and remove any `test = false`. Move premium-adapter tests out of community. + Audit and remove any `test = false`. + cargo test -p admin-api; cargo test -p mcp-server + + + + Community release tag v0.2.0 + Bump workspace version to 0.2.0. + just fmt; just check; just test; npm --prefix apps/ui run build; npm --prefix apps/ui run e2e. + Commit + push to crank-community/main; tag v0.2.0 and push the tag. + `git ls-remote --tags crank-community` lists v0.2.0. + + + + + Move the enterprise workspace to its real shape: community as submodule, premium adapters + crank-proto in private crates, inlined logic from enterprise/apps/admin-api lifted into dedicated crates. + + + Bootstrap submodule and root workspace + Inside crank-enterprise working copy: `git submodule add -b main git@github.com:bsodfather/crank-community.git vendor/crank-community` + `cd vendor/crank-community && git checkout v0.2.0 && cd ../..` + + [workspace] + members = [ + "crates/crank-proto", + "crates/crank-adapter-graphql", + "crates/crank-adapter-grpc", + "crates/crank-adapter-soap", + "crates/crank-adapter-websocket", + "crates/crank-enterprise-auth", + "crates/crank-enterprise-tokens", + "crates/crank-enterprise-rbac", + "crates/crank-enterprise-audit", + "crates/crank-enterprise-protocols", + "crates/crank-enterprise-descriptors", + "apps/enterprise-admin-api", + "apps/enterprise-mcp-server", + "apps/enterprise-ui-overlay", + ] + resolver = "3" + + [workspace.dependencies] + crank-core = { path = "vendor/crank-community/crates/crank-core" } + crank-registry = { path = "vendor/crank-community/crates/crank-registry" } + crank-runtime = { path = "vendor/crank-community/crates/crank-runtime" } + crank-schema = { path = "vendor/crank-community/crates/crank-schema" } + crank-mapping = { path = "vendor/crank-community/crates/crank-mapping" } + crank-adapter-rest = { path = "vendor/crank-community/crates/crank-adapter-rest" } + crank-community-auth = { path = "vendor/crank-community/crates/crank-community-auth" } + crank-community-mcp = { path = "vendor/crank-community/crates/crank-community-mcp" } + + cargo metadata --no-deps lists vendor crates and is empty of enterprise crates so far; submodule pinned at v0.2.0. + + + + Import crank-proto and premium adapters from community deletion + The four adapter crates and crank-proto were physically deleted from community in task 1.1. Pull their source directly from a community commit BEFORE the deletion (community git history is the canonical source). + `git -C vendor/crank-community show v0.2.0^:crates/crank-proto` → copy tree into crates/crank-proto/ + Same for crank-adapter-graphql/grpc/soap/websocket. + + Add impl ProtocolAdapter for each adapter (deferred from task 0.9 since the crates were not in the same workspace as crank-core at that point). supports_mode per S1: GraphQL=Unary, Grpc=Unary|Window, Soap=Unary, Websocket=Window|Session. + + + Use workspace dep aliases: `crank-core.workspace = true`. + + cargo check --workspace + + + + Create crank-enterprise-rbac (S5 enterprise impl) + + Deps: crank-core (workspace), async-trait, thiserror. + + + Pub struct RbacPolicyEngine. + impl PolicyEngine — full owner/admin/operator/viewer matrix lifted from enterprise/apps/admin-api/src/service.rs (search "MembershipRole::" branches). + Matrix: + Owner -> Allow all + Admin -> Allow all EXCEPT DeleteWorkspace, ManageSsoProviders (Owner-only) + Operator-> Allow Read*, ManageOperations, RunTests, ManageAgents, RotateAgentKeys, IssueMachineTokens, ManageSecrets + Viewer -> Allow Read* only + + cargo test -p crank-enterprise-rbac + + + + Create crank-enterprise-audit (S6 enterprise impl + S12 extension) + + Deps: crank-core (workspace), crank-registry (workspace), async-trait, sqlx (workspace), serde, serde_json, time, tracing, thiserror, uuid. + + + CREATE TABLE IF NOT EXISTS audit_events ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + actor_user_id TEXT, + actor_email TEXT, + actor_session_id TEXT, + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT NOT NULL, + payload JSONB NOT NULL, + source_ip TEXT, + user_agent TEXT, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + CREATE INDEX IF NOT EXISTS audit_events_workspace_occurred ON audit_events (workspace_id, occurred_at DESC); + + + pub mod sink; // pub struct PostgresAuditSink { pool }; impl AuditSink + pub mod extension; // pub struct AuditEventsExtension; impl RegistryExtension { name="enterprise.audit_events"; migrations=[V0001] } + + cargo test -p crank-enterprise-audit + + + + Create crank-enterprise-auth (S4-v2 SSO/TOTP impl + S12 extensions) + + Deps: crank-core (workspace), crank-community-auth (workspace), crank-registry (workspace), async-trait, axum, axum-extra, base64, reqwest, serde, serde_json, sha2, sqlx, thiserror, time, tracing, totp-rs = "5", uuid. + + + sso_providers, totp_factors, pending_two_factor_tickets — see Phase 2 r1 task 2.4 SQL block. + + + pub mod oidc; // OidcIdentityProvider; impl IdentityProvider with start_sso / complete_sso + pub mod totp; // TotpFactorStore + TotpEnforcer<P: IdentityProvider> + pub mod composite; // CompositeIdentityProvider wrapping password (wrapped in TotpEnforcer) and oidc (wrapped in TotpEnforcer) + pub mod extension; // SsoProvidersExtension, TotpFactorsExtension impls of RegistryExtension + + + IMPORTANT: This edit happens in community, not enterprise. It widens the IdentityProvider trait to S4-v2 — adds default-implemented SSO/TOTP methods. Bump community to v0.3.0 and update submodule. + + Source for method bodies: enterprise/apps/admin-api/src/service.rs (SSO + TOTP regions). Behavior preserved 1:1. + cargo test -p crank-enterprise-auth + + + + Create crank-enterprise-tokens (S3 enterprise impl + S12) + + Deps: crank-core (workspace), crank-registry (workspace), crank-runtime (workspace), async-trait, base64, rand, serde, serde_json, sha2, sqlx, thiserror, time, tracing, uuid. + + + machine_tokens table — see r1 task 2.5 SQL. + + + pub mod issuer; // EnterpriseMachineTokenIssuer; impl MachineTokenIssuer (lifted from enterprise/apps/admin-api/src/service.rs) + pub mod verifier; // EnterpriseMachineCredentialVerifier; impl MachineCredentialVerifier (lifted from enterprise/apps/mcp-server/src/auth.rs) + pub mod store; // MachineTokenStore trait + PostgresMachineTokenStore + pub mod replay_guard; // uses crank_runtime::ReplayGuardStore via cache layer + pub mod extension; // MachineTokensExtension impl of RegistryExtension + + cargo test -p crank-enterprise-tokens + + + + Create crank-enterprise-descriptors + The proto/wsdl/xsd upload routes and gRPC/SOAP service listings stripped from community admin-api in task 1.2 reappear here as a route module + service that gets mounted by enterprise-admin-api. + + Deps: crank-core (workspace), crank-proto (path = "../crank-proto"), crank-adapter-soap (path = "../crank-adapter-soap"), crank-registry (workspace), axum, serde, serde_json, thiserror, tracing. + + + pub mod service; // DescriptorService — methods lifted from old community service.rs (list_grpc_services, list_soap_services, upload_proto_descriptor, upload_descriptor_set, upload_wsdl_descriptor, upload_xsd_descriptor) + pub mod routes; // axum::Router::new()...mount onto AdminService's workspace router + + cargo test -p crank-enterprise-descriptors + + + + Create crank-enterprise-protocols + + Deps: crank-core (workspace), crank-runtime (workspace), crank-adapter-graphql (path), crank-adapter-grpc (path), crank-adapter-soap (path), crank-adapter-websocket (path). + + + pub fn register_enterprise_adapters(builder: RuntimeExecutorBuilder) -> RuntimeExecutorBuilder { + builder + .register_adapter(Arc::new(GraphqlAdapter::new())) + .register_adapter(Arc::new(GrpcAdapter::new())) + .register_adapter(Arc::new(SoapAdapter::new())) + .register_adapter(Arc::new(WebsocketAdapter::new())) + } + + pub struct EnterpriseCapabilityProfile; + impl CapabilityProfile for EnterpriseCapabilityProfile { /* full matrix */ } + + cargo test -p crank-enterprise-protocols + + + + Create enterprise-admin-api binary + + Deps: axum, tokio, tracing, tracing-subscriber, sqlx, crank-community-auth (workspace), crank-runtime (workspace), crank-registry (workspace), crank-enterprise-auth (path), crank-enterprise-tokens (path), crank-enterprise-rbac (path), crank-enterprise-audit (path), crank-enterprise-protocols (path), crank-enterprise-descriptors (path). + + + Pseudocode: + 1. Parse env (reuse helpers; copy from community admin-api main). + 2. Connect Postgres; apply community migrations + extension migrations for: AuditEventsExtension, SsoProvidersExtension, TotpFactorsExtension, MachineTokensExtension via crank_registry::ext::apply_extension_migrations. + 3. Runtime: let runtime = crank_runtime::community_default().with_limits(...).pipe(register_enterprise_adapters).with_response_cache(...).build(); + 4. Build identity: password = PasswordIdentityProvider; oidc = OidcIdentityProvider; totp_store = TotpFactorStore; identity = CompositeIdentityProvider { password: TotpEnforcer::new(password, totp_store.clone()), oidc: TotpEnforcer::new(oidc, totp_store.clone()) }; + 5. AdminServiceBuilder::new(...).with_identity_provider(identity).with_audit_sink(PostgresAuditSink).with_policy_engine(RbacPolicyEngine).with_token_issuer(EnterpriseMachineTokenIssuer).with_capability_profile(EnterpriseCapabilityProfile).build(); + 6. Build community admin-api Router (reuse crank-community's admin-api::build_app via a thin re-export) + mount crank_enterprise_descriptors::routes::router() + mount enterprise-only auth routes (sso_start, sso_callback, totp_enroll, totp_verify, totp_disable, manage_sso_providers, list_audit_events, get_audit_event). + + Step 6 needs community's `apps/admin-api::build_app` to be callable from outside the binary. If it is private today, expose it through crank-community-mcp-equivalent route library, or extract a thin `crank-community-admin-routes` library AT THIS POINT (justified now: cross-repo consumer = enterprise-admin-api). This is the only retroactive crate addition allowed; it was NOT justified earlier because there was no consumer. + cargo run -p enterprise-admin-api --check + + + + Create enterprise-mcp-server binary + + Deps: axum, tokio, tracing, tracing-subscriber, sqlx, crank-community-mcp (workspace), crank-runtime (workspace), crank-enterprise-tokens (path), crank-enterprise-protocols (path), crank-registry (workspace). + + + let runtime = crank_runtime::community_default().with_limits(...).pipe(register_enterprise_adapters).with_response_cache(...).build(); + let verifier: SharedMachineCredentialVerifier = Arc::new(EnterpriseMachineCredentialVerifier::new(registry.clone(), ...)); + let app = crank_community_mcp::build_app(... , verifier); + + cargo test -p enterprise-mcp-server + + + + Lift enterprise tests alongside methods + Per risk R5: every method lifted from enterprise/apps/admin-api/service.rs and enterprise/apps/mcp-server/auth.rs moves its tests in the same commit. + cargo test --workspace inside crank-enterprise; behavior is byte-identical to the previous enterprise/apps/admin-api integration tests. + + + + Enterprise release tag v0.2.0 + cargo fmt --all; cargo test --workspace. + Commit + push crank-enterprise/main; tag v0.2.0. + + + + + Stand up crank-cloud workspace with submodules, introduce Wave 3 seams in community, implement them in cloud. + + + Add Wave 3 seam traits in community + Introduce S7 (Wave 2), S8, S9, S10 in crank-core::ext just before they are first consumed. + + Per seam definitions. Community wires NoopMeteringSink/NoopBillingHook/SingleTenantController/BuiltinCacheBackendFactory as defaults; admin-api / mcp-server / runtime gain new builder slots wired to defaults so nothing changes for community. + + Bump community to v0.3.0, push tag, update submodule pointer in enterprise and cloud. + community workspace builds; admin-api capabilities response unchanged. + + + + Bootstrap crank-cloud workspace + git submodule add crank-community at v0.3.0 to vendor/crank-community. + git submodule add crank-enterprise at v0.2.0 to vendor/crank-enterprise. + + members: crates/crank-cloud-tenants, crates/crank-cloud-metering, crates/crank-cloud-billing, crates/crank-cloud-control-plane, apps/cloud-admin-api, apps/cloud-mcp-server, apps/cloud-control-plane. + workspace.dependencies: all community + enterprise crates pointing at vendor/. + + + + + Create crank-cloud-tenants (S10) + extension + + CREATE TABLE tenants (id TEXT PRIMARY KEY, slug TEXT UNIQUE NOT NULL, billing_status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()); + ALTER TABLE workspaces ADD COLUMN IF NOT EXISTS tenant_id TEXT REFERENCES tenants(id); + + + Pub struct HostedTenantController; impl TenantController. + Pub struct TenantsExtension; impl RegistryExtension. + + + + + Create crank-cloud-metering (S8) + extension + + metering_events table + index; usage_rollups table. + + + Pub struct UsageMeteringSink { pool, tenant_resolver, buffer: tokio::sync::mpsc, flush_task }. + impl MeteringSink — push event into channel; background task batches inserts every 5s. + Rollup job: aggregates into usage_rollups per workspace/period. + + + + + Create crank-cloud-billing (S9) + extension + + billing_subscriptions + billing_records. + + + Stripe (or chosen provider) integration; impl BillingHook. + + + + + Create crank-cloud-control-plane (S7 + CloudCapabilityProfile + cloud admin routes) + + Pub struct ManagedCacheBackendFactory; impl CacheBackendFactory. + Pub struct CloudCapabilityProfile; impl CapabilityProfile (Enterprise capabilities + ProductEdition::Cloud + per-tenant limits resolved via TenantController). + Cloud admin routes module: list_tenants, create_tenant, suspend_tenant, force_flush_metering. + + + + + Create cloud-admin-api and cloud-mcp-server binaries + + Same wiring as enterprise-admin-api plus: + TenantController: HostedTenantController + CacheBackendFactory: ManagedCacheBackendFactory + MeteringSink: UsageMeteringSink + BillingHook: StripeBillingHook + CapabilityProfile: CloudCapabilityProfile + Mounts the extra cloud admin routes from crank-cloud-control-plane. + + + Same as enterprise-mcp-server plus metering sink injection into RuntimeExecutorBuilder. + + + + + Cloud release tag v0.2.0 + cargo build --workspace --release inside crank-cloud succeeds; cloud-admin-api boots against a Postgres + Redis sandbox and records a MeteringEvent. + + + + + Stop pretending the top-level /home/github-ops/crank baseline is a working copy. After Phase 1.7 / 2.12 / 3.8, all three real repos exist and operate independently. The remaining root-level baseline artifacts are dead weight. + + + Decide what becomes of /home/github-ops/crank (top-level) + + + + + delete-root-artifacts. Nested repositories already exist and are authoritative. + After whichever option: any daily-work git operation in /home/github-ops/crank itself is forbidden; only inside community/, enterprise/, cloud/. + + + + Update split-related docs + Mark §2 (Current state) as "split complete". Update §7 to reflect that physical split is done; only sync remains. + Replace the "Immediate Next Step" section with "Split complete. Day-to-day sync proceeds through git submodule bumps per docs/modular_decomposition.xml §local-dev-workflow." + Append revision r3 noting Phase 4 outcome. + + + + Delete or archive root-level baseline artifacts + + The three nested repositories inside /home/github-ops/crank are the real working copies and MUST be preserved. Phase 4 cleans only the legacy root-level baseline artifacts around them. + + + + + Implement hybrid UI per A5: shared base UI stays in community, build-time overlay package in enterprise adds truly private panels. + + + Add slot registry to community UI + + Defines the closed set of slot ids: + settings.sso_panel + settings.totp_panel + settings.audit_panel + settings.billing_panel + wizard.protocol_cards.graphql + wizard.protocol_cards.grpc + wizard.protocol_cards.soap + wizard.protocol_cards.websocket + Each slot has a function `render(target: Element, ctx: { workspace, capabilities, locale })`. + Default render is a noop (community ships no implementations for any of these). + + + On startup, fetch `/overlay/manifest.json`. If present, register slot handlers from declared js files (only slots in slot-registry.js are accepted). + + + If env CRANK_UI_OVERLAY_DIR is set, copy that directory into dist/overlay before bundling. + + Add <div data-slot="settings.sso_panel"></div> etc. Slots render nothing in community build. + Community UI build green. No SOAP/GraphQL etc panels appear in community. + + + + Enterprise UI overlay package + name="crank-enterprise-ui-overlay". + + { + "slots": { + "settings.sso_panel": "sso-panel.js", + "settings.totp_panel": "totp-panel.js", + "settings.audit_panel": "audit-panel.js", + "wizard.protocol_cards.graphql": "graphql-card.js", + "wizard.protocol_cards.grpc": "grpc-card.js", + "wizard.protocol_cards.soap": "soap-card.js", + "wizard.protocol_cards.websocket": "websocket-card.js" + } + } + + One file per slot. + + In enterprise CI: + cd vendor/crank-community + CRANK_UI_OVERLAY_DIR=$(pwd)/../../apps/enterprise-ui-overlay node apps/ui/scripts/build.js + + Resulting dist has SSO/TOTP/audit panels + the four extra wizard cards visible. + + + + Cloud UI overlay package + + Adds the billing panel: + "settings.billing_panel": "billing-panel.js" + Cloud overlay merges enterprise overlay first, then layers cloud-specific slots on top. + + + + + + Reproducible signed delivery per repo. + + + Community release pipeline + + Trigger: push tag v*. + Steps: build admin-api, mcp-server, UI dist; build Docker images; push to ghcr.io/<owner>/crank-community-{admin-api,mcp-server}; attach checksums + SBOM; create GitHub Release. + + + + + Enterprise release pipeline + + Push tag v* triggers: fetch submodule at the pinned community tag; build enterprise-admin-api / enterprise-mcp-server / enterprise-ui-overlay; build Docker images; push to private registry; sign with cosign; produce SBOM. + + + + + Cloud release pipeline + control-plane rollout + + Build cloud-admin-api / cloud-mcp-server / cloud-control-plane; push to managed registry; trigger blue/green rollout in hosted environment. + + + + + Unblock live authenticated staging pass + Operator: restore reachability of rmcp.itexp.me:443; provision disposable bootstrap admin creds. + Run full authenticated pass per docs/authenticated-staging-pass.md; record results into docs/staging-regression-notes.md. + + + + + + + + After Phase 0, monorepo `just test` is green and every existing behavior is preserved (binary diffs allowed only inside method bodies replaced by trait calls). + After Phase 1, community workspace contains zero references to graphql/grpc/soap/websocket/proto. UI build succeeds without those protocols. crank-community v0.2.0 is tagged. + After Phase 2, crank-enterprise workspace builds with community at vendor/crank-community@v0.2.0; enterprise-admin-api boots and returns Enterprise capability payload; existing enterprise tests pass byte-identical to before lift. + After Phase 3, crank-cloud workspace builds; cloud-admin-api records a MeteringEvent into metering_events; community v0.3.0 added Wave 3 seam traits without breaking community behavior. + After Phase 4, root-level legacy baseline artifacts are gone or archived; daily work happens only inside the three real working copies. + After Phase 6, a `git tag v0.x` push to each repo produces signed release artifacts in the correct registry. + + + + + + + + Trait granularity wrong + Phase 0 enforces zero behavior change. Any newly discovered seam requirement that does not fit existing traits triggers a small follow-up PR in community before more private code is added — never extend community with a method that exists "for enterprise only" without a community-meaningful default. + + + RegistryExtension migrations applied to wrong database + Each extension owns its schema. Migrations are idempotent and tracked in __crank_ext_migrations. Community binary never registers enterprise extensions, therefore never applies enterprise tables. + + + Submodule drift + Each private repo CI runs `git submodule update --init --recursive` and fails the build if the submodule HEAD is on a branch (it must be on a tagged commit). Automated PR opens when community tags a new release. + + + UI slot collisions + Slot ids are closed-set in apps/ui/js/slot-registry.js. overlay-loader.js validates each manifest entry against this registry and refuses unknown slot ids. + + + Test coverage regression during Phase 2 lift + Every method moved from enterprise/apps/admin-api/service.rs into a private crate moves its associated test from enterprise/apps/admin-api/tests/ in the same commit. Tracked as task 2.11. + + + Wave 1 trait widening (S4-v2) is a community breaking change + S4-v2 adds default-implemented methods only. Existing community code does not implement IdentityProvider, so no external impls break. LoginOutcome is intentionally documented as an open enum (no exhaustive matches in community) so adding PendingTwoFactor is backward compatible. + + + Diploma artifact desync + Diploma chapter 2 currently describes the monolithic architecture. After Phase 0 lands, update it to describe the seam architecture as the documented design. Sync once at Phase 0, once at Phase 4. + + + + + + + + Execute tasks in order within a phase. Tasks with parallel="true" can be batched. Phases are strictly sequential. + Each task produces one commit on a feature branch named `feat/modular-decomp-<phase>-<task_id>` (e.g. `feat/modular-decomp-0-3`). + Commit message format: `decomp(P<phase>.<task_id>): <task name>`. + If a task's verification fails, do not proceed. Open a follow-up commit on the same branch to fix the regression. Never silently skip verification. + When a task says "lift from enterprise/...", the lifted code must be byte-identical apart from import path changes. Behavioral changes belong in a separate follow-up task. + Workspace edits to Cargo.toml are sensitive: do not reorder unrelated members. Add new entries at the end of `[workspace] members`. + Phase 0 and 1 are executed in the community working copy. Phase 2 in crank-enterprise. Phase 3 in crank-cloud. Phase 4 spans all three. + Document references in commits using `Refs: docs/modular_decomposition.xml#<phase_id>.<task_id>`. + When a phase requires a community seam widening (e.g. task 2.5 adds S4-v2 to community), that widening is a separate community-side PR + tag bump that lands BEFORE the consuming enterprise/cloud task. + + +