Files
crank/docs/modular_decomposition.xml
T
github-ops 15d7cfa8d9
CI / Rust Checks (push) Has been cancelled
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
Deploy / build-images (apps/admin-api/Dockerfile, git.itexp.me/bsodfather/crank-community-admin-api, admin-api) (push) Has been cancelled
Deploy / build-images (apps/mcp-server/Dockerfile, git.itexp.me/bsodfather/crank-community-mcp-server, mcp-server) (push) Has been cancelled
Deploy / build-images (apps/ui/Dockerfile, git.itexp.me/bsodfather/crank-community-ui, ui) (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
ci: migrate community workflows to gitea
2026-05-26 21:49:09 +00:00

1662 lines
100 KiB
XML

<?xml version="1.0" encoding="UTF-8"?>
<!--
Detailed migration plan for splitting `crank` into a plugin-based open-core
architecture across three repositories:
crank-community (public, strict REST-only host)
crank-enterprise (private, self-hosted commercial plugins)
crank-cloud (private, hosted SaaS plugins)
Revision history:
- r1 (2026-05-11): initial draft.
- r2 (2026-05-11): revised after codex review (see review-notes + response-notes).
Format is consumed by codex as a task list. Each <task> is intentionally
small and self-contained so codex can execute one at a time and land an
independent commit. Tasks are ordered by dependency. Within a phase tasks
can be parallelized only where explicitly marked parallel="true".
Source-of-truth references when reading this file:
/home/github-ops/crank/ -> top-level baseline working copy (origin: bsodfather/crank)
/home/github-ops/crank/community/ -> separate working copy (origin: crank-community)
/home/github-ops/crank/enterprise/ -> separate working copy (origin: crank-enterprise)
/home/github-ops/crank/cloud/ -> separate working copy (origin: crank-cloud)
/home/github-ops/crank/community/docs/repository-split-map.md
/home/github-ops/crank/community/docs/implementation-plan.md
/home/github-ops/crank/community/docs/product-editions.md
/home/github-ops/crank/community/docs/commercial-boundaries.md
-->
<plan version="2" generated="2026-05-11">
<!-- ============================================================== -->
<!-- REVIEW NOTES (kept verbatim from r1; do not edit) -->
<!-- ============================================================== -->
<review-notes author="codex" date="2026-05-11">
<summary>
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.
</summary>
<strengths>
<item id="R-S1">
The host/plugin principle is correct: crank-community should know
only public traits and contracts, while enterprise/cloud supply
private implementations.
</item>
<item id="R-S2">
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.
</item>
<item id="R-S3">
The migration model for private database tables via additive
extensions is correct. It is better than shipping enterprise/cloud
schema inside the community baseline.
</item>
<item id="R-S4">
The document correctly treats commercial security and governance
as server-side functionality, not as “hidden code in public
sources”.
</item>
</strengths>
<issues>
<issue id="R-I1" severity="high">
<title>Principle A7 is already stale</title>
<detail>
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.
</detail>
</issue>
<issue id="R-I2" severity="high">
<title>Community topology and protocol boundary are not fully aligned</title>
<detail>
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.
</detail>
</issue>
<issue id="R-I3" severity="high">
<title>The plan introduces new crates too early</title>
<detail>
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.
</detail>
</issue>
<issue id="R-I4" severity="medium">
<title>UI overlay model may over-fragment the product surface</title>
<detail>
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.
</detail>
</issue>
<issue id="R-I5" severity="medium">
<title>Dependency strategy is correct for releases but underspecified for daily development</title>
<detail>
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.
</detail>
</issue>
<issue id="R-I6" severity="medium">
<title>Not all seams are equally urgent</title>
<detail>
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.
</detail>
</issue>
</issues>
<questions>
<question id="R-Q1">
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?
</question>
<question id="R-Q2">
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?
</question>
<question id="R-Q3">
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?
</question>
<question id="R-Q4">
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.
</question>
<question id="R-Q5">
Which seams are approved for the first implementation wave, and
which are intentionally deferred to avoid over-engineering the
migration?
</question>
</questions>
<recommended-cut>
<item order="1">
Update the document so it reflects the current split state rather
than the pre-split monorepo state.
</item>
<item order="2">
Freeze the first-wave seam set:
ProtocolAdapterRegistry,
MachineCredentialVerifier,
MachineTokenIssuer,
CapabilityProfile,
PolicyEngine,
AuditSink,
RegistryExtension.
</item>
<item order="3">
Keep new functionality in modules first. Create new crates only
when the seam is stable and independent enough to justify a
separate crate.
</item>
<item order="4">
Resolve the community/public boundary around crank-proto and any
remaining protocol-related public crates before moving more code.
</item>
<item order="5">
Choose and document one concrete local dependency workflow across
community, enterprise and cloud.
</item>
</recommended-cut>
</review-notes>
<!-- ============================================================== -->
<!-- RESPONSE NOTES (r2) -->
<!-- ============================================================== -->
<response-notes author="claude" date="2026-05-11">
<summary>
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.
</summary>
<decision ref="R-Q1">
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.
</decision>
<decision ref="R-Q2">
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.
</decision>
<decision ref="R-Q3">
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.
</decision>
<decision ref="R-Q4">
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.
</decision>
<decision ref="R-Q5">
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.
</decision>
<addressed-issue ref="R-I1">
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".
</addressed-issue>
<addressed-issue ref="R-I2">
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).
</addressed-issue>
<addressed-issue ref="R-I3">
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.
</addressed-issue>
<addressed-issue ref="R-I4">
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.
</addressed-issue>
<addressed-issue ref="R-I5">
Daily development workflow recorded as principle A9 (git submodule)
with concrete folder layout and Cargo.toml shape. A separate
&lt;local-dev-workflow&gt; section captures the exact developer commands.
</addressed-issue>
<addressed-issue ref="R-I6">
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.
</addressed-issue>
</response-notes>
<!-- ============================================================== -->
<!-- 1. ARCHITECTURE -->
<!-- ============================================================== -->
<architecture>
<principle id="A1">
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.
</principle>
<principle id="A2">
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.
</principle>
<principle id="A3">
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.
</principle>
<principle id="A4">
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.
</principle>
<principle id="A5">
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.
</principle>
<principle id="A6">
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.
</principle>
<principle id="A7">
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.
</principle>
<principle id="A8">
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.
</principle>
<principle id="A9">
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.
</principle>
<principle id="A10">
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.
</principle>
<principle id="A11">
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.
</principle>
<principle id="A12">
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.
</principle>
</architecture>
<!-- ============================================================== -->
<!-- 2. LOCAL DEVELOPMENT WORKFLOW -->
<!-- ============================================================== -->
<local-dev-workflow>
<description>
Concrete developer setup so the three repositories stay coherent
day to day. This is the implementation of principle A9.
</description>
<setup repo="crank-community">
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.
</setup>
<setup repo="crank-enterprise">
<step>git clone git@github.com:bsodfather/crank-enterprise.git</step>
<step>git submodule add -b main git@github.com:bsodfather/crank-community.git vendor/crank-community</step>
<step>git submodule update --init --recursive</step>
<step>cargo build --workspace</step>
<cargo_dependency_shape>
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" }
</cargo_dependency_shape>
</setup>
<setup repo="crank-cloud">
<step>git clone git@github.com:bsodfather/crank-cloud.git</step>
<step>git submodule add -b main git@github.com:bsodfather/crank-community.git vendor/crank-community</step>
<step>git submodule add -b main git@github.com:bsodfather/crank-enterprise.git vendor/crank-enterprise</step>
<step>git submodule update --init --recursive</step>
<step>cargo build --workspace</step>
<cargo_dependency_shape>
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" }
</cargo_dependency_shape>
</setup>
<release_bump>
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.
</release_bump>
<rules>
<rule>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.</rule>
<rule>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.</rule>
<rule>CI in private repos runs `git submodule update --init --recursive` before any cargo command.</rule>
</rules>
</local-dev-workflow>
<!-- ============================================================== -->
<!-- 3. TARGET WORKSPACE TOPOLOGY -->
<!-- ============================================================== -->
<topology>
<repo name="crank-community" visibility="public">
<workspace_members>
<member>crates/crank-core</member>
<member>crates/crank-schema</member>
<member>crates/crank-mapping</member>
<member>crates/crank-registry</member>
<member>crates/crank-runtime</member>
<member>crates/crank-adapter-rest</member>
<member>crates/crank-community-auth</member>
<member>crates/crank-community-mcp</member>
<member>apps/admin-api</member>
<member>apps/mcp-server</member>
</workspace_members>
<separate_apps>
<app>apps/ui (separate frontend app; not a Cargo workspace member)</app>
</separate_apps>
<not_in_workspace>
crank-proto, crank-adapter-graphql, crank-adapter-grpc,
crank-adapter-soap, crank-adapter-websocket. None of these
belong in the public REST-only host.
</not_in_workspace>
<crate_justifications>
<crate name="crank-community-auth">
Justified: consumed by crank-enterprise-auth, which wraps
PasswordIdentityProvider in TotpEnforcer. Cross-repo consumer.
</crate>
<crate name="crank-community-mcp">
Justified: consumed by enterprise-mcp-server, which reuses
jsonrpc / catalog / session and replaces only the credential
verifier. Cross-repo consumer.
</crate>
</crate_justifications>
</repo>
<repo name="crank-enterprise" visibility="private">
<workspace_members>
<member>crates/crank-proto (moved from community)</member>
<member>crates/crank-adapter-graphql (moved from community)</member>
<member>crates/crank-adapter-grpc (moved from community)</member>
<member>crates/crank-adapter-soap (moved from community)</member>
<member>crates/crank-adapter-websocket (moved from community)</member>
<member>crates/crank-enterprise-auth (SSO + TOTP + recovery codes)</member>
<member>crates/crank-enterprise-tokens (short-lived + one-time + verifier)</member>
<member>crates/crank-enterprise-rbac (owner/admin/operator/viewer policy)</member>
<member>crates/crank-enterprise-audit (audit_events sink + extension)</member>
<member>crates/crank-enterprise-protocols (adapter registry helper + EnterpriseCapabilityProfile)</member>
<member>crates/crank-enterprise-descriptors (proto/wsdl/xsd upload service for admin-api)</member>
<member>apps/enterprise-admin-api (wires community admin-api scaffold + enterprise crates + descriptor routes)</member>
<member>apps/enterprise-mcp-server (wires community-mcp + enterprise verifier + enterprise adapter registry)</member>
<member>apps/enterprise-ui-overlay (build-time UI overlay package)</member>
</workspace_members>
<vendor>vendor/crank-community/ (submodule, pinned to a community tag)</vendor>
</repo>
<repo name="crank-cloud" visibility="private">
<workspace_members>
<member>crates/crank-cloud-tenants (HostedTenantController)</member>
<member>crates/crank-cloud-metering (UsageMeteringSink)</member>
<member>crates/crank-cloud-billing (StripeBillingHook or alt)</member>
<member>crates/crank-cloud-control-plane (ManagedCacheBackendFactory + cloud admin routes)</member>
<member>apps/cloud-admin-api</member>
<member>apps/cloud-mcp-server</member>
<member>apps/cloud-control-plane</member>
</workspace_members>
<vendor>
vendor/crank-community/ (submodule)
vendor/crank-enterprise/ (submodule)
</vendor>
</repo>
</topology>
<!-- ============================================================== -->
<!-- 4. SEAM CATALOG -->
<!-- Every trait that defines a plug-in boundary. -->
<!-- All seams live under crank-core::ext after Phase 0. -->
<!-- ============================================================== -->
<seams>
<seam id="S1" name="ProtocolAdapterRegistry" wave="1" location="crank-core::ext::protocol">
<intent>Replace the hard-coded fields on RuntimeExecutor with a registry of adapters keyed by Protocol.</intent>
<trait name="ProtocolAdapter">
<method>fn protocol(&amp;self) -> Protocol;</method>
<method>async fn invoke_unary(&amp;self, prepared: &amp;PreparedRequest, ctx: &amp;RuntimeRequestContext) -> Result&lt;AdapterResponse, RuntimeError&gt;;</method>
<method>async fn invoke_window(&amp;self, prepared: &amp;PreparedRequest, ctx: &amp;RuntimeRequestContext) -> Result&lt;WindowExecutionResult, RuntimeError&gt; { /* default: RuntimeError::Unsupported */ }</method>
<method>async fn open_session(&amp;self, prepared: &amp;PreparedRequest, ctx: &amp;RuntimeRequestContext) -> Result&lt;StreamSession, RuntimeError&gt; { /* default: RuntimeError::Unsupported */ }</method>
<method>fn supports_mode(&amp;self, mode: ExecutionMode) -> bool;</method>
</trait>
<community_default>crank-adapter-rest exports `pub struct RestAdapter` and `impl ProtocolAdapter for RestAdapter`. Community RuntimeExecutorBuilder registers only this adapter.</community_default>
<enterprise_impl>crank-adapter-{graphql,grpc,soap,websocket} each `impl ProtocolAdapter`. crank-enterprise-protocols::register_enterprise_adapters(builder) adds all four to a builder.</enterprise_impl>
</seam>
<seam id="S2" name="MachineCredentialVerifier" wave="1" location="crank-core::ext::auth">
<intent>Already exists at apps/mcp-server/src/auth.rs:11-23. Lift into crank-core unchanged; the StaticAgentKeyVerifier stays in community as the default.</intent>
<trait name="MachineCredentialVerifier">
<method>async fn verify_bearer_token(&amp;self, workspace_slug: &amp;str, agent_slug: &amp;str, token: &amp;str) -> Result&lt;Option&lt;VerifiedMachineCredential&gt;, MachineCredentialVerifierError&gt;;</method>
</trait>
<community_default>StaticAgentKeyVerifier (renamed from CommunityMachineCredentialVerifier) — accepts only platform_api_key matches, returns MachineAccessMode::StaticAgentKey + Standard security_level.</community_default>
<enterprise_impl>EnterpriseMachineCredentialVerifier in crank-enterprise-tokens.</enterprise_impl>
</seam>
<seam id="S3" name="MachineTokenIssuer" wave="1" location="crank-core::ext::auth">
<intent>Replace direct calls from admin-api routes to service-internal token stubs with a trait. Community returns NotSupportedInEdition; Enterprise issues real tokens.</intent>
<trait name="MachineTokenIssuer">
<method>async fn issue_short_lived(&amp;self, req: IssueAgentTokenRequest, actor: &amp;TokenIssuerActor) -> Result&lt;IssuedAgentTokenResponse, TokenIssuerError&gt;;</method>
<method>async fn issue_one_time(&amp;self, req: IssueOneTimeAgentTokenRequest, actor: &amp;TokenIssuerActor) -> Result&lt;IssuedAgentTokenResponse, TokenIssuerError&gt;;</method>
</trait>
<community_default>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.</community_default>
<enterprise_impl>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`).</enterprise_impl>
<types>
TokenIssuerActor { user_id: UserId, workspace_id: WorkspaceId, role: MembershipRole }
TokenIssuerError { NotSupportedInEdition, InvalidGrant(String), AgentKeyUnknown, OperationNotStrict, OperationNotPublishedForAgent, RegistryFailure(String), ReplayGuardFailure(String) }
</types>
</seam>
<seam id="S4-v1" name="IdentityProvider (password-only)" wave="1" location="crank-core::ext::auth">
<intent>Pluggable identity backend for /api/auth/login. First wave covers only password login; SSO and TOTP methods are added in S4-v2 (Wave 2).</intent>
<trait name="IdentityProvider">
<method>fn id(&amp;self) -> &amp;str;</method>
<method>fn kind(&amp;self) -> IdentityProviderKind;</method>
<method>async fn login_password(&amp;self, payload: LoginPayload) -> Result&lt;LoginOutcome, IdentityError&gt;;</method>
</trait>
<enums>
IdentityProviderKind { Password }
LoginOutcome { Authenticated(AuthenticatedSession) }
IdentityError { NotSupportedForProvider, BadCredentials, AccountDisabled, Internal(String) }
</enums>
<community_default>PasswordIdentityProvider (in crank-community-auth) — current Argon2 + session cookie flow.</community_default>
<note>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.</note>
</seam>
<seam id="S4-v2" name="IdentityProvider (SSO + TOTP extension)" wave="2" location="crank-core::ext::auth">
<intent>Widen the IdentityProvider trait when enterprise SSO/TOTP code physically lands in crank-enterprise-auth.</intent>
<added_methods>
<method>async fn start_sso(&amp;self, workspace: &amp;WorkspaceId, return_to: Option&lt;String&gt;) -> Result&lt;StartSsoOutcome, IdentityError&gt; { /* default: Err(NotSupportedForProvider) */ }</method>
<method>async fn complete_sso(&amp;self, params: SsoCallbackParams) -> Result&lt;LoginOutcome, IdentityError&gt; { /* default */ }</method>
<method>async fn enroll_totp(&amp;self, user: &amp;UserId) -> Result&lt;TotpEnrollment, IdentityError&gt; { /* default */ }</method>
<method>async fn verify_totp(&amp;self, user: &amp;UserId, code: &amp;str) -> Result&lt;(), IdentityError&gt; { /* default */ }</method>
<method>async fn disable_totp(&amp;self, user: &amp;UserId) -> Result&lt;(), IdentityError&gt; { /* default */ }</method>
</added_methods>
<enum_addition>
LoginOutcome += PendingTwoFactor(PendingTwoFactorTicket)
</enum_addition>
<enterprise_impl>OidcIdentityProvider (per-workspace) + TotpEnforcer wrapper. Both in crank-enterprise-auth.</enterprise_impl>
</seam>
<seam id="S5" name="PolicyEngine" wave="1" location="crank-core::ext::access">
<intent>Replace ad-hoc role checks scattered across admin-api routes with one PolicyEngine.</intent>
<trait name="PolicyEngine">
<method>fn check(&amp;self, actor: &amp;SessionActor, action: PolicyAction, scope: PolicyScope) -> PolicyDecision;</method>
</trait>
<enums>
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: &amp;'static str } }
</enums>
<community_default>OwnerOnlyPolicyEngine — only MembershipRole::Owner is allowed for write actions; reads allowed to any member.</community_default>
<enterprise_impl>RbacPolicyEngine — owner/admin/operator/viewer matrix (lift from enterprise admin-api).</enterprise_impl>
</seam>
<seam id="S6" name="AuditSink" wave="1" location="crank-core::ext::audit">
<intent>Every state-changing admin route emits an AuditEvent. Community discards; Enterprise persists.</intent>
<trait name="AuditSink">
<method>async fn record(&amp;self, event: AuditEvent) -> Result&lt;(), AuditError&gt;;</method>
<method>async fn list(&amp;self, q: AuditQuery) -> Result&lt;Page&lt;AuditEvent&gt;, AuditError&gt; { /* default: empty page */ }</method>
<method>async fn get(&amp;self, id: &amp;AuditEventId) -> Result&lt;Option&lt;AuditEvent&gt;, AuditError&gt; { /* default: None */ }</method>
</trait>
<types>
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 }
</types>
<community_default>NoopAuditSink.</community_default>
<enterprise_impl>PostgresAuditSink in crank-enterprise-audit.</enterprise_impl>
</seam>
<seam id="S11" name="CapabilityProfile" wave="1" location="crank-core::ext::capability">
<intent>Replace the hard-coded body of AdminService::get_capabilities with a trait.</intent>
<trait name="CapabilityProfile">
<method>fn capabilities(&amp;self) -> EditionCapabilities;</method>
</trait>
<community_default>CommunityCapabilityProfile — current AdminService::get_capabilities body (REST / Standard / StaticAgentKey / 1,1,1 limits).</community_default>
<enterprise_impl>EnterpriseCapabilityProfile in crank-enterprise-protocols.</enterprise_impl>
<cloud_impl>CloudCapabilityProfile in crank-cloud-control-plane.</cloud_impl>
</seam>
<seam id="S12" name="RegistryExtension" wave="1" location="crank-registry::ext">
<intent>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.</intent>
<trait name="RegistryExtension">
<method>fn name(&amp;self) -> &amp;str;</method>
<method>fn migrations(&amp;self) -> &amp;[ExtensionMigration];</method>
</trait>
<types>
ExtensionMigration { version: u32, sql: &amp;'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));
</types>
<community_default>No extensions registered.</community_default>
<enterprise_impl>AuditEventsExtension, SsoProvidersExtension, TotpFactorsExtension, MachineTokensExtension.</enterprise_impl>
<cloud_impl>TenantsExtension, MeteringExtension, BillingExtension.</cloud_impl>
</seam>
<seam id="S7" name="CacheBackendFactory" wave="2" location="crank-runtime::cache_factory">
<intent>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.</intent>
<trait name="CacheBackendFactory">
<method>async fn build(&amp;self, config: &amp;RuntimeCacheConfig) -> Result&lt;RuntimeCacheStores, RuntimeCacheStoreInitError&gt;;</method>
</trait>
<community_default>BuiltinCacheBackendFactory — wraps existing crank-runtime::cache code.</community_default>
<cloud_impl>ManagedCacheBackendFactory in crank-cloud-control-plane.</cloud_impl>
</seam>
<seam id="S8" name="MeteringSink" wave="3" location="crank-core::ext::metering">
<intent>Runtime emits a MeteringEvent on every invocation completion. Community drops it; Cloud forwards to the billing pipeline.</intent>
<trait name="MeteringSink">
<method>async fn record(&amp;self, event: MeteringEvent);</method>
</trait>
<community_default>NoopMeteringSink.</community_default>
<cloud_impl>UsageMeteringSink in crank-cloud-metering.</cloud_impl>
</seam>
<seam id="S9" name="BillingHook" wave="3" location="crank-core::ext::billing">
<intent>Reacts to MeteringSink rollups. Pure Cloud concern.</intent>
<trait name="BillingHook">
<method>async fn on_rollup(&amp;self, rollup: UsageRollup) -> Result&lt;(), BillingError&gt;;</method>
<method>async fn check_billing_gate(&amp;self, tenant: &amp;TenantId) -> Result&lt;BillingGate, BillingError&gt;;</method>
</trait>
<community_default>NoopBillingHook (Allow).</community_default>
<cloud_impl>StripeBillingHook in crank-cloud-billing.</cloud_impl>
</seam>
<seam id="S10" name="TenantController" wave="3" location="crank-core::ext::tenancy">
<intent>Multi-tenant context resolution.</intent>
<trait name="TenantController">
<method>fn resolve_tenant(&amp;self, request: &amp;TenantResolutionContext) -> Result&lt;TenantId, TenancyError&gt;;</method>
<method>async fn list_workspaces_for_tenant(&amp;self, tenant: &amp;TenantId) -> Result&lt;Vec&lt;WorkspaceId&gt;, TenancyError&gt;;</method>
</trait>
<community_default>SingleTenantController (returns TenantId::new("default")).</community_default>
<cloud_impl>HostedTenantController in crank-cloud-tenants.</cloud_impl>
</seam>
</seams>
<!-- ============================================================== -->
<!-- 5. INVENTORY -->
<!-- ============================================================== -->
<inventory>
<classify path="crates/crank-core/" target="crank-community" notes="stays; gains ext/ submodule with Wave 1 seam traits"/>
<classify path="crates/crank-schema/" target="crank-community" notes="stays unchanged"/>
<classify path="crates/crank-mapping/" target="crank-community" notes="stays unchanged"/>
<classify path="crates/crank-registry/" target="crank-community" notes="stays; gains ext/ submodule for RegistryExtension trait + __crank_ext_migrations tracking"/>
<classify path="crates/crank-runtime/" target="crank-community" notes="stays. Protocol feature flags removed (no graphql/grpc/soap/websocket features). Executor uses AdapterRegistry."/>
<classify path="crates/crank-adapter-rest/" target="crank-community" notes="stays; gains impl ProtocolAdapter"/>
<classify path="crates/crank-proto/" target="crank-enterprise" notes="MOVED to crank-enterprise. Pure gRPC/protobuf tooling, no REST use case."/>
<classify path="crates/crank-adapter-graphql/" target="crank-enterprise" notes="moves to enterprise repo"/>
<classify path="crates/crank-adapter-grpc/" target="crank-enterprise" notes="moves to enterprise repo"/>
<classify path="crates/crank-adapter-soap/" target="crank-enterprise" notes="moves to enterprise repo"/>
<classify path="crates/crank-adapter-websocket/" target="crank-enterprise" notes="moves to enterprise repo"/>
<classify path="apps/admin-api/src/auth.rs" target="crank-community-auth" notes="extracted to new community crate. Module split per task 1.4."/>
<classify path="apps/admin-api/src/service.rs" target="apps/admin-api (stays)" notes="Service stays inside the binary. Touched files: token stubs replaced by MachineTokenIssuer trait calls; capability hardcode replaced by CapabilityProfile trait; gRPC/SOAP/proto methods MOVED OUT to crank-enterprise-descriptors (along with their admin-api routes)."/>
<classify path="apps/admin-api/src/routes/*" target="apps/admin-api (stays)" notes="route files stay. Handlers call seam traits via AppState. Descriptor upload routes for non-REST protocols MOVED OUT (see below)."/>
<classify path="apps/admin-api/src/routes/operations.rs handlers: upload_proto_descriptor, upload_descriptor_set, list_grpc_services, list_soap_services, upload_wsdl_descriptor, upload_xsd_descriptor" target="crank-enterprise-descriptors (route module) + apps/enterprise-admin-api" notes="These handlers depend on crank-proto / soap-wsdl tooling that lives only in enterprise. They are stripped from community admin-api in task 1.1 and reintroduced as an enterprise route module in task 2.7."/>
<classify path="apps/admin-api/src/routes/machine_auth.rs" target="apps/admin-api (stays)" notes="route stays; handler delegates to MachineTokenIssuer trait"/>
<classify path="apps/admin-api/src/routes/capabilities.rs" target="apps/admin-api (stays)" notes="delegates to CapabilityProfile trait"/>
<classify path="apps/mcp-server/src/auth.rs" target="crank-community-mcp" notes="trait moves to crank-core::ext::auth (Phase 0). CommunityMachineCredentialVerifier renamed to StaticAgentKeyVerifier and moved to crank-community-mcp."/>
<classify path="apps/mcp-server/src/{catalog.rs,jsonrpc.rs,session.rs,app.rs}" target="crank-community-mcp" notes="lifted to community library crate so enterprise-mcp-server can reuse jsonrpc + catalog + session and swap only the verifier"/>
<classify path="apps/mcp-server/src/main.rs" target="apps/mcp-server (stays, thinned)" notes="binary keeps env parsing + DI wiring; delegates to crank_community_mcp::build_app"/>
<classify path="apps/ui/" target="crank-community" notes="stays. Premium-only strings and SOAP/GraphQL/gRPC/WebSocket wizard cards removed in task 1.3. Slot-loader added in task 5.1."/>
<classify path="community/" target="crank-community working copy" notes="this IS the canonical community repo working copy. Keep operating in this folder for community work."/>
<classify path="enterprise/" target="canonical crank-enterprise working copy" notes="contains the real private self-hosted repository and is the only valid place for enterprise-side changes."/>
<classify path="cloud/" target="canonical crank-cloud working copy" notes="contains the real private cloud repository and is the only valid place for cloud-side changes."/>
<classify path="/home/github-ops/crank/ (top-level root)" target="retire root-level baseline artifacts in Phase 4" notes="legacy root container. Keep only docs/reference material that has not yet been migrated, then delete or archive root-level artifacts without touching the three nested repositories."/>
</inventory>
<!-- ============================================================== -->
<!-- 6. PHASES -->
<!-- ============================================================== -->
<phase id="0" name="Wave 1 seams in community" location="community working copy">
<goal>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.</goal>
<task id="0.1" parallel="false">
<name>Create crank-core::ext module skeleton</name>
<create path="crates/crank-core/src/ext/mod.rs">
pub mod protocol;
pub mod auth;
pub mod access;
pub mod audit;
pub mod capability;
</create>
<edit path="crates/crank-core/src/lib.rs">Add `pub mod ext;` at the end of the module list.</edit>
<verification>cargo check --workspace</verification>
</task>
<task id="0.2" parallel="false">
<name>Lift MachineCredentialVerifier into crank-core (S2)</name>
<intent>Trait already exists at apps/mcp-server/src/auth.rs:11-23. Move it byte-identical apart from imports.</intent>
<create path="crates/crank-core/src/ext/auth.rs">
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&lt;PlatformApiKeyScope&gt;,
}
#[derive(Debug, thiserror::Error)]
#[error("machine credential verifier failed")]
pub struct MachineCredentialVerifierError;
#[async_trait]
pub trait MachineCredentialVerifier: Send + Sync {
async fn verify_bearer_token(
&amp;self,
workspace_slug: &amp;str,
agent_slug: &amp;str,
token: &amp;str,
) -&gt; Result&lt;Option&lt;VerifiedMachineCredential&gt;, MachineCredentialVerifierError&gt;;
}
pub type SharedMachineCredentialVerifier = std::sync::Arc&lt;dyn MachineCredentialVerifier&gt;;
</create>
<edit path="apps/mcp-server/src/auth.rs">
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.
</edit>
<verification>cargo check --workspace; cargo test -p mcp-server --no-run</verification>
</task>
<task id="0.3" parallel="false">
<name>Define MachineTokenIssuer (S3) and NoMachineTokenIssuer</name>
<create path="crates/crank-core/src/ext/auth.rs (append)">
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(&amp;self, req: IssueAgentTokenRequest, actor: &amp;TokenIssuerActor) -&gt; Result&lt;IssuedAgentTokenResponse, TokenIssuerError&gt;;
async fn issue_one_time(&amp;self, req: IssueOneTimeAgentTokenRequest, actor: &amp;TokenIssuerActor) -&gt; Result&lt;IssuedAgentTokenResponse, TokenIssuerError&gt;;
}
pub type SharedMachineTokenIssuer = std::sync::Arc&lt;dyn MachineTokenIssuer&gt;;
pub struct NoMachineTokenIssuer;
#[async_trait]
impl MachineTokenIssuer for NoMachineTokenIssuer {
async fn issue_short_lived(&amp;self, _: IssueAgentTokenRequest, _: &amp;TokenIssuerActor) -&gt; Result&lt;IssuedAgentTokenResponse, TokenIssuerError&gt; { Err(TokenIssuerError::NotSupportedInEdition) }
async fn issue_one_time(&amp;self, _: IssueOneTimeAgentTokenRequest, _: &amp;TokenIssuerActor) -&gt; Result&lt;IssuedAgentTokenResponse, TokenIssuerError&gt; { Err(TokenIssuerError::NotSupportedInEdition) }
}
</create>
<verification>cargo check --workspace</verification>
</task>
<task id="0.4" parallel="false">
<name>Define IdentityProvider Wave 1 (S4-v1) and PasswordIdentityProvider scaffold</name>
<create path="crates/crank-core/src/ext/auth.rs (append)">
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(&amp;self) -&gt; &amp;str;
fn kind(&amp;self) -&gt; IdentityProviderKind;
async fn login_password(&amp;self, payload: LoginPayload) -&gt; Result&lt;LoginOutcome, IdentityError&gt;;
}
pub type SharedIdentityProvider = std::sync::Arc&lt;dyn IdentityProvider&gt;;
</create>
<note>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&lt;...&gt; for AuthenticatedSession` inside admin-api. Concrete PasswordIdentityProvider lands in crank-community-auth in task 1.4.</note>
<verification>cargo check --workspace</verification>
</task>
<task id="0.5" parallel="false">
<name>Define PolicyEngine (S5) and OwnerOnlyPolicyEngine</name>
<create path="crates/crank-core/src/ext/access.rs">
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.
</create>
<verification>cargo check --workspace</verification>
</task>
<task id="0.6" parallel="false">
<name>Define AuditSink (S6) and NoopAuditSink</name>
<create path="crates/crank-core/src/ext/audit.rs">
Types AuditEventId, AuditActor, AuditTargetKind, AuditTarget, AuditEvent, AuditQuery, AuditError.
Trait AuditSink with default `list`/`get` returning empty.
Default impl NoopAuditSink.
</create>
<verification>cargo check --workspace</verification>
</task>
<task id="0.7" parallel="false">
<name>Define CapabilityProfile (S11) and CommunityCapabilityProfile</name>
<create path="crates/crank-core/src/ext/capability.rs">
Trait CapabilityProfile { fn capabilities(&amp;self) -&gt; EditionCapabilities }.
Pub struct CommunityCapabilityProfile.
Impl returns exactly the same body currently in apps/admin-api/src/service.rs::get_capabilities (lines around 1570-1582).
</create>
<verification>cargo check --workspace</verification>
</task>
<task id="0.8" parallel="false">
<name>Define ProtocolAdapter (S1) and AdapterRegistry</name>
<create path="crates/crank-core/src/ext/protocol.rs">
Trait ProtocolAdapter (per S1).
Pub struct AdapterRegistry with empty(), register(self, adapter), get(protocol), protocols().
Type alias SharedProtocolAdapter = Arc&lt;dyn ProtocolAdapter&gt;.
</create>
<verification>cargo check --workspace</verification>
</task>
<task id="0.9" parallel="false">
<name>Implement ProtocolAdapter for crank-adapter-rest</name>
<edit path="crates/crank-adapter-rest/src/lib.rs">
impl crank_core::ext::protocol::ProtocolAdapter for RestAdapter {
fn protocol(&amp;self) -&gt; crank_core::Protocol { crank_core::Protocol::Rest }
fn supports_mode(&amp;self, mode: crank_core::ExecutionMode) -&gt; bool { matches!(mode, ExecutionMode::Unary | ExecutionMode::Window) }
async fn invoke_unary(...) { /* wraps existing send_request -> AdapterResponse */ }
async fn invoke_window(...) { /* wraps existing window method */ }
}
</edit>
<edit path="crates/crank-adapter-rest/Cargo.toml">Confirm `crank-core = { path = "../crank-core" }` exists.</edit>
<note>Adapter impls for graphql/grpc/soap/websocket land in Phase 2 — when those crates are physically moved into crank-enterprise.</note>
<verification>cargo check --workspace</verification>
</task>
<task id="0.10" parallel="false">
<name>RuntimeExecutor switches to AdapterRegistry</name>
<intent>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.</intent>
<edit path="crates/crank-runtime/src/executor.rs">
- 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.
</edit>
<edit path="crates/crank-runtime/Cargo.toml">
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 = []`.
</edit>
<verification>cargo check -p crank-runtime; cargo test -p crank-runtime --no-run</verification>
</task>
<task id="0.11" parallel="false">
<name>Add RuntimeExecutorBuilder + community_default()</name>
<create path="crates/crank-runtime/src/executor_builder.rs">
pub struct RuntimeExecutorBuilder {
limits: RuntimeLimits,
adapters: AdapterRegistry,
response_cache: Option&lt;Arc&lt;dyn ResponseCacheStore&gt;&gt;,
}
impl RuntimeExecutorBuilder {
pub fn new() -&gt; Self
pub fn with_limits(mut self, limits: RuntimeLimits) -&gt; Self
pub fn register_adapter(mut self, adapter: SharedProtocolAdapter) -&gt; Self
pub fn with_response_cache(mut self, store: Arc&lt;dyn ResponseCacheStore&gt;) -&gt; Self
pub fn build(self) -&gt; RuntimeExecutor
}
pub fn community_default() -&gt; RuntimeExecutorBuilder {
RuntimeExecutorBuilder::new().register_adapter(Arc::new(crank_adapter_rest::RestAdapter::new()))
}
</create>
<edit path="crates/crank-runtime/src/lib.rs">Re-export RuntimeExecutorBuilder and community_default.</edit>
<verification>cargo check --workspace</verification>
</task>
<task id="0.12" parallel="false">
<name>Wire community_default() in community binaries</name>
<edit path="apps/admin-api/src/main.rs">
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();
</edit>
<edit path="apps/mcp-server/src/main.rs">Same replacement.</edit>
<verification>just test</verification>
</task>
<task id="0.13" parallel="false">
<name>RegistryExtension (S12) in crank-registry</name>
<create path="crates/crank-registry/src/ext.rs">
pub struct ExtensionMigration { pub version: u32, pub sql: &amp;'static str }
pub trait RegistryExtension: Send + Sync {
fn name(&amp;self) -&gt; &amp;str;
fn migrations(&amp;self) -&gt; &amp;[ExtensionMigration];
}
pub async fn apply_extension_migrations(pool: &amp;PgPool, extensions: &amp;[Arc&lt;dyn RegistryExtension&gt;]) -&gt; Result&lt;(), RegistryError&gt; {
// ensure __crank_ext_migrations exists
// for each extension, for each migration not yet applied, run SQL inside a transaction + insert tracking row
}
</create>
<edit path="crates/crank-registry/src/lib.rs">`pub mod ext;` and re-export.</edit>
<verification>cargo test -p crank-registry</verification>
</task>
<task id="0.14" parallel="false">
<name>AdminServiceBuilder with seam slots</name>
<edit path="apps/admin-api/src/service.rs">
Replace direct constructor with builder:
pub struct AdminServiceBuilder {
registry: PostgresRegistry,
storage_root: PathBuf,
auth_settings: AuthSettings,
secret_crypto: SecretCrypto,
runtime: RuntimeExecutor,
identity_provider: Option&lt;Arc&lt;dyn IdentityProvider&gt;&gt;,
policy_engine: Option&lt;Arc&lt;dyn PolicyEngine&gt;&gt;,
audit_sink: Option&lt;Arc&lt;dyn AuditSink&gt;&gt;,
token_issuer: Option&lt;Arc&lt;dyn MachineTokenIssuer&gt;&gt;,
capability_profile: Option&lt;Arc&lt;dyn CapabilityProfile&gt;&gt;,
}
impl AdminServiceBuilder {
pub fn new(registry, storage_root, auth_settings, secret_crypto, runtime) -&gt; Self
pub fn with_identity_provider(mut self, p: Arc&lt;dyn IdentityProvider&gt;) -&gt; Self
pub fn with_policy_engine(mut self, p: Arc&lt;dyn PolicyEngine&gt;) -&gt; Self
pub fn with_audit_sink(mut self, s: Arc&lt;dyn AuditSink&gt;) -&gt; Self
pub fn with_token_issuer(mut self, i: Arc&lt;dyn MachineTokenIssuer&gt;) -&gt; Self
pub fn with_capability_profile(mut self, p: Arc&lt;dyn CapabilityProfile&gt;) -&gt; Self
pub fn build(self) -&gt; 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.
</edit>
<edit path="apps/admin-api/src/main.rs">Use AdminServiceBuilder.</edit>
<verification>cargo test -p admin-api --no-run</verification>
</task>
<task id="0.15" parallel="false">
<name>Route handlers delegate to seam traits</name>
<edit path="apps/admin-api/src/routes/capabilities.rs">
Replace `state.service.get_capabilities().await` with a call that goes through `state.service.capability_profile().capabilities()`.
</edit>
<edit path="apps/admin-api/src/routes/machine_auth.rs">
Call `state.service.token_issuer().issue_short_lived(...)`. Map TokenIssuerError::NotSupportedInEdition into ApiError::Forbidden with body `{ "edition": &lt;current&gt;, "machine_access_mode": "short_lived_token", "grant_type": ..., "upgrade_required": true }` so the wire response matches today.
</edit>
<edit path="apps/admin-api/src/service.rs">
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.
</edit>
<edit path="apps/admin-api/src/routes/access.rs">
Every write handler calls `state.service.policy_engine().check(...)` and bails on Deny BEFORE doing work. Audit emit goes after successful mutation.
</edit>
<verification>cargo test -p admin-api</verification>
</task>
<task id="0.16" parallel="false">
<name>Phase 0 verification gate</name>
<verification>
- 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.
</verification>
</task>
</phase>
<phase id="1" name="Strict-REST community + minimal crate extraction" location="community working copy">
<goal>Make community strict REST-only (per A8) and extract the two justified library crates.</goal>
<task id="1.1" parallel="false">
<name>Remove non-REST adapters and crank-proto from community workspace</name>
<delete path="crates/crank-adapter-graphql/"/>
<delete path="crates/crank-adapter-grpc/"/>
<delete path="crates/crank-adapter-soap/"/>
<delete path="crates/crank-adapter-websocket/"/>
<delete path="crates/crank-proto/"/>
<edit path="Cargo.toml">Remove the corresponding `[workspace] members` entries.</edit>
<note>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.</note>
<verification>cargo check --workspace; cargo test --workspace --no-run</verification>
</task>
<task id="1.2" parallel="false">
<name>Strip non-REST descriptor routes from community admin-api</name>
<edit path="apps/admin-api/src/routes/operations.rs">
Remove handlers: upload_proto_descriptor, upload_descriptor_set, list_grpc_services, list_soap_services, upload_wsdl_descriptor, upload_xsd_descriptor.
</edit>
<edit path="apps/admin-api/src/app.rs">Remove the matching `.route(...)` lines and their imports.</edit>
<edit path="apps/admin-api/src/service.rs">
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).
</edit>
<edit path="apps/ui/js/wizard*">Remove protocol cards / wizard branches for graphql, grpc, soap, websocket. Community wizard becomes REST-only.</edit>
<edit path="apps/ui/html/wizard/step1.html">Remove SOAP / GraphQL / gRPC / WebSocket protocol cards (keep only REST). UI strings for these protocols removed from apps/ui/js/i18n.js.</edit>
<verification>cargo test -p admin-api; npm --prefix apps/ui run build</verification>
</task>
<task id="1.3" parallel="false">
<name>Drop dormant premium UI strings</name>
<edit path="apps/ui/html/login.html">Remove the `login.coming_soon` note and `login.sso_short` button entirely.</edit>
<edit path="apps/ui/js/i18n.js">
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.
</edit>
<edit path="apps/ui/dist/">Rebuild via `node apps/ui/scripts/build.js`.</edit>
<verification>npm --prefix apps/ui run build; npm --prefix apps/ui run e2e -- --grep "REST"</verification>
</task>
<task id="1.4" parallel="false">
<name>Create crank-community-auth crate</name>
<intent>Justified by R-Q2/A10: TotpEnforcer in enterprise wraps PasswordIdentityProvider — cross-repo consumer exists.</intent>
<create path="crates/crank-community-auth/Cargo.toml">
name = "crank-community-auth"
Deps: crank-core, crank-registry, argon2, axum-extra (cookie), base64, rand, time, sha2, uuid, async-trait, thiserror, tracing.
</create>
<create path="crates/crank-community-auth/src/lib.rs">
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
</create>
<edit path="apps/admin-api/src/auth.rs">
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).
</edit>
<edit path="Cargo.toml">Add crates/crank-community-auth to `[workspace] members`.</edit>
<edit path="apps/admin-api/Cargo.toml">Add `crank-community-auth = { path = "../../crates/crank-community-auth" }`.</edit>
<edit path="apps/admin-api/src/main.rs">
let identity = Arc::new(PasswordIdentityProvider::new(registry.clone(), auth_settings.clone()));
let service = AdminServiceBuilder::new(...).with_identity_provider(identity).build();
</edit>
<verification>cargo test -p crank-community-auth; cargo test -p admin-api</verification>
</task>
<task id="1.5" parallel="false">
<name>Create crank-community-mcp crate</name>
<intent>Justified: enterprise-mcp-server reuses jsonrpc + catalog + session, swaps only the verifier.</intent>
<create path="crates/crank-community-mcp/Cargo.toml">
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.
</create>
<create path="crates/crank-community-mcp/src/lib.rs">
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
</create>
<edit path="apps/mcp-server/src/*">
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(...)`.
</edit>
<edit path="Cargo.toml">Add crates/crank-community-mcp to workspace members.</edit>
<verification>cargo test -p crank-community-mcp; cargo test -p mcp-server</verification>
</task>
<task id="1.6" parallel="false">
<name>Replace `test = false` ballast</name>
<edit path="apps/admin-api/Cargo.toml">Audit and remove any `test = false`. Move premium-adapter tests out of community.</edit>
<edit path="apps/mcp-server/Cargo.toml">Audit and remove any `test = false`.</edit>
<verification>cargo test -p admin-api; cargo test -p mcp-server</verification>
</task>
<task id="1.7" parallel="false">
<name>Community release tag v0.2.0</name>
<step>Bump workspace version to 0.2.0.</step>
<step>just fmt; just check; just test; npm --prefix apps/ui run build; npm --prefix apps/ui run e2e.</step>
<step>Commit + push to crank-community/main; tag v0.2.0 and push the tag.</step>
<verification>`git ls-remote --tags crank-community` lists v0.2.0.</verification>
</task>
</phase>
<phase id="2" name="Enterprise crates extraction" location="crank-enterprise working copy">
<goal>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.</goal>
<task id="2.1" parallel="false">
<name>Bootstrap submodule and root workspace</name>
<step>Inside crank-enterprise working copy: `git submodule add -b main git@github.com:bsodfather/crank-community.git vendor/crank-community`</step>
<step>`cd vendor/crank-community &amp;&amp; git checkout v0.2.0 &amp;&amp; cd ../..`</step>
<edit path="Cargo.toml">
[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" }
</edit>
<verification>cargo metadata --no-deps lists vendor crates and is empty of enterprise crates so far; submodule pinned at v0.2.0.</verification>
</task>
<task id="2.2" parallel="false">
<name>Import crank-proto and premium adapters from community deletion</name>
<intent>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).</intent>
<step>`git -C vendor/crank-community show v0.2.0^:crates/crank-proto` → copy tree into crates/crank-proto/</step>
<step>Same for crank-adapter-graphql/grpc/soap/websocket.</step>
<edit path="crates/crank-adapter-{graphql,grpc,soap,websocket}/src/lib.rs">
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.
</edit>
<edit path="crates/crank-adapter-{graphql,grpc,soap,websocket}/Cargo.toml">
Use workspace dep aliases: `crank-core.workspace = true`.
</edit>
<verification>cargo check --workspace</verification>
</task>
<task id="2.3" parallel="false">
<name>Create crank-enterprise-rbac (S5 enterprise impl)</name>
<create path="crates/crank-enterprise-rbac/Cargo.toml">
Deps: crank-core (workspace), async-trait, thiserror.
</create>
<create path="crates/crank-enterprise-rbac/src/lib.rs">
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
</create>
<verification>cargo test -p crank-enterprise-rbac</verification>
</task>
<task id="2.4" parallel="false">
<name>Create crank-enterprise-audit (S6 enterprise impl + S12 extension)</name>
<create path="crates/crank-enterprise-audit/Cargo.toml">
Deps: crank-core (workspace), crank-registry (workspace), async-trait, sqlx (workspace), serde, serde_json, time, tracing, thiserror, uuid.
</create>
<create path="crates/crank-enterprise-audit/migrations/V0001__audit_events.sql">
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);
</create>
<create path="crates/crank-enterprise-audit/src/lib.rs">
pub mod sink; // pub struct PostgresAuditSink { pool }; impl AuditSink
pub mod extension; // pub struct AuditEventsExtension; impl RegistryExtension { name="enterprise.audit_events"; migrations=[V0001] }
</create>
<verification>cargo test -p crank-enterprise-audit</verification>
</task>
<task id="2.5" parallel="false">
<name>Create crank-enterprise-auth (S4-v2 SSO/TOTP impl + S12 extensions)</name>
<create path="crates/crank-enterprise-auth/Cargo.toml">
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.
</create>
<create path="crates/crank-enterprise-auth/migrations/V0001__sso_totp.sql">
sso_providers, totp_factors, pending_two_factor_tickets — see Phase 2 r1 task 2.4 SQL block.
</create>
<create path="crates/crank-enterprise-auth/src/lib.rs">
pub mod oidc; // OidcIdentityProvider; impl IdentityProvider with start_sso / complete_sso
pub mod totp; // TotpFactorStore + TotpEnforcer&lt;P: IdentityProvider&gt;
pub mod composite; // CompositeIdentityProvider wrapping password (wrapped in TotpEnforcer) and oidc (wrapped in TotpEnforcer)
pub mod extension; // SsoProvidersExtension, TotpFactorsExtension impls of RegistryExtension
</create>
<edit path="vendor/crank-community/.../crank-core/src/ext/auth.rs">
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.
</edit>
<note>Source for method bodies: enterprise/apps/admin-api/src/service.rs (SSO + TOTP regions). Behavior preserved 1:1.</note>
<verification>cargo test -p crank-enterprise-auth</verification>
</task>
<task id="2.6" parallel="false">
<name>Create crank-enterprise-tokens (S3 enterprise impl + S12)</name>
<create path="crates/crank-enterprise-tokens/Cargo.toml">
Deps: crank-core (workspace), crank-registry (workspace), crank-runtime (workspace), async-trait, base64, rand, serde, serde_json, sha2, sqlx, thiserror, time, tracing, uuid.
</create>
<create path="crates/crank-enterprise-tokens/migrations/V0001__machine_tokens.sql">
machine_tokens table — see r1 task 2.5 SQL.
</create>
<create path="crates/crank-enterprise-tokens/src/lib.rs">
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
</create>
<verification>cargo test -p crank-enterprise-tokens</verification>
</task>
<task id="2.7" parallel="false">
<name>Create crank-enterprise-descriptors</name>
<intent>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.</intent>
<create path="crates/crank-enterprise-descriptors/Cargo.toml">
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.
</create>
<create path="crates/crank-enterprise-descriptors/src/lib.rs">
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
</create>
<verification>cargo test -p crank-enterprise-descriptors</verification>
</task>
<task id="2.8" parallel="false">
<name>Create crank-enterprise-protocols</name>
<create path="crates/crank-enterprise-protocols/Cargo.toml">
Deps: crank-core (workspace), crank-runtime (workspace), crank-adapter-graphql (path), crank-adapter-grpc (path), crank-adapter-soap (path), crank-adapter-websocket (path).
</create>
<create path="crates/crank-enterprise-protocols/src/lib.rs">
pub fn register_enterprise_adapters(builder: RuntimeExecutorBuilder) -&gt; 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 */ }
</create>
<verification>cargo test -p crank-enterprise-protocols</verification>
</task>
<task id="2.9" parallel="false">
<name>Create enterprise-admin-api binary</name>
<create path="apps/enterprise-admin-api/Cargo.toml">
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).
</create>
<create path="apps/enterprise-admin-api/src/main.rs">
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).
</create>
<note>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.</note>
<verification>cargo run -p enterprise-admin-api --check</verification>
</task>
<task id="2.10" parallel="false">
<name>Create enterprise-mcp-server binary</name>
<create path="apps/enterprise-mcp-server/Cargo.toml">
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).
</create>
<create path="apps/enterprise-mcp-server/src/main.rs">
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);
</create>
<verification>cargo test -p enterprise-mcp-server</verification>
</task>
<task id="2.11" parallel="false">
<name>Lift enterprise tests alongside methods</name>
<intent>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.</intent>
<verification>cargo test --workspace inside crank-enterprise; behavior is byte-identical to the previous enterprise/apps/admin-api integration tests.</verification>
</task>
<task id="2.12" parallel="false">
<name>Enterprise release tag v0.2.0</name>
<step>cargo fmt --all; cargo test --workspace.</step>
<step>Commit + push crank-enterprise/main; tag v0.2.0.</step>
</task>
</phase>
<phase id="3" name="Cloud crates + Wave 3 seams" location="crank-cloud working copy">
<goal>Stand up crank-cloud workspace with submodules, introduce Wave 3 seams in community, implement them in cloud.</goal>
<task id="3.1" parallel="false">
<name>Add Wave 3 seam traits in community</name>
<intent>Introduce S7 (Wave 2), S8, S9, S10 in crank-core::ext just before they are first consumed.</intent>
<create path="vendor/crank-community/.../crates/crank-core/src/ext/{cache.rs,metering.rs,billing.rs,tenancy.rs}">
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.
</create>
<step>Bump community to v0.3.0, push tag, update submodule pointer in enterprise and cloud.</step>
<verification>community workspace builds; admin-api capabilities response unchanged.</verification>
</task>
<task id="3.2" parallel="false">
<name>Bootstrap crank-cloud workspace</name>
<step>git submodule add crank-community at v0.3.0 to vendor/crank-community.</step>
<step>git submodule add crank-enterprise at v0.2.0 to vendor/crank-enterprise.</step>
<edit path="Cargo.toml">
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/.
</edit>
</task>
<task id="3.3" parallel="false">
<name>Create crank-cloud-tenants (S10) + extension</name>
<create path="crates/crank-cloud-tenants/migrations/V0001__tenants.sql">
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);
</create>
<create path="crates/crank-cloud-tenants/src/lib.rs">
Pub struct HostedTenantController; impl TenantController.
Pub struct TenantsExtension; impl RegistryExtension.
</create>
</task>
<task id="3.4" parallel="false">
<name>Create crank-cloud-metering (S8) + extension</name>
<create path="crates/crank-cloud-metering/migrations/V0001__metering.sql">
metering_events table + index; usage_rollups table.
</create>
<create path="crates/crank-cloud-metering/src/lib.rs">
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>
</task>
<task id="3.5" parallel="false">
<name>Create crank-cloud-billing (S9) + extension</name>
<create path="crates/crank-cloud-billing/migrations/V0001__billing.sql">
billing_subscriptions + billing_records.
</create>
<create path="crates/crank-cloud-billing/src/lib.rs">
Stripe (or chosen provider) integration; impl BillingHook.
</create>
</task>
<task id="3.6" parallel="false">
<name>Create crank-cloud-control-plane (S7 + CloudCapabilityProfile + cloud admin routes)</name>
<create path="crates/crank-cloud-control-plane/src/lib.rs">
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>
</task>
<task id="3.7" parallel="false">
<name>Create cloud-admin-api and cloud-mcp-server binaries</name>
<create path="apps/cloud-admin-api/src/main.rs">
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.
</create>
<create path="apps/cloud-mcp-server/src/main.rs">
Same as enterprise-mcp-server plus metering sink injection into RuntimeExecutorBuilder.
</create>
</task>
<task id="3.8" parallel="false">
<name>Cloud release tag v0.2.0</name>
<verification>cargo build --workspace --release inside crank-cloud succeeds; cloud-admin-api boots against a Postgres + Redis sandbox and records a MeteringEvent.</verification>
</task>
</phase>
<phase id="4" name="Retire top-level baseline" location="all repos">
<goal>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.</goal>
<task id="4.1" parallel="false">
<name>Decide what becomes of /home/github-ops/crank (top-level)</name>
<options>
<option name="delete-root-artifacts">Remove root-level legacy baseline artifacts; daily work happens inside community/, enterprise/, cloud/ subfolders.</option>
<option name="freeze-root-as-readonly-container">Keep only root-level docs/reference artifacts and treat the root as a read-only historical container. No product commits, no pulls.</option>
</options>
<recommendation>delete-root-artifacts. Nested repositories already exist and are authoritative.</recommendation>
<verification>After whichever option: any daily-work git operation in /home/github-ops/crank itself is forbidden; only inside community/, enterprise/, cloud/.</verification>
</task>
<task id="4.2" parallel="false">
<name>Update split-related docs</name>
<edit path="docs/repository-split-map.md">Mark §2 (Current state) as "split complete". Update §7 to reflect that physical split is done; only sync remains.</edit>
<edit path="docs/repository-sync-strategy.md">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."</edit>
<edit path="docs/modular_decomposition.xml">Append revision r3 noting Phase 4 outcome.</edit>
</task>
<task id="4.3" parallel="false">
<name>Delete or archive root-level baseline artifacts</name>
<delete path="/home/github-ops/crank/<root-level files and directories except community/, enterprise/, cloud/>"/>
<note>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.</note>
</task>
</phase>
<phase id="5" name="UI hybrid overlay" location="community + enterprise">
<goal>Implement hybrid UI per A5: shared base UI stays in community, build-time overlay package in enterprise adds truly private panels.</goal>
<task id="5.1" parallel="false" location="crank-community">
<name>Add slot registry to community UI</name>
<create path="apps/ui/js/slot-registry.js">
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).
</create>
<create path="apps/ui/js/overlay-loader.js">
On startup, fetch `/overlay/manifest.json`. If present, register slot handlers from declared js files (only slots in slot-registry.js are accepted).
</create>
<edit path="apps/ui/scripts/build.js">
If env CRANK_UI_OVERLAY_DIR is set, copy that directory into dist/overlay before bundling.
</edit>
<edit path="apps/ui/html/settings.html">Add &lt;div data-slot="settings.sso_panel"&gt;&lt;/div&gt; etc. Slots render nothing in community build.</edit>
<verification>Community UI build green. No SOAP/GraphQL etc panels appear in community.</verification>
</task>
<task id="5.2" parallel="false" location="crank-enterprise">
<name>Enterprise UI overlay package</name>
<create path="apps/enterprise-ui-overlay/package.json">name="crank-enterprise-ui-overlay".</create>
<create path="apps/enterprise-ui-overlay/manifest.json">
{
"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"
}
}
</create>
<create path="apps/enterprise-ui-overlay/*.js">One file per slot.</create>
<build_step>
In enterprise CI:
cd vendor/crank-community
CRANK_UI_OVERLAY_DIR=$(pwd)/../../apps/enterprise-ui-overlay node apps/ui/scripts/build.js
</build_step>
<verification>Resulting dist has SSO/TOTP/audit panels + the four extra wizard cards visible.</verification>
</task>
<task id="5.3" parallel="false" location="crank-cloud">
<name>Cloud UI overlay package</name>
<create path="apps/cloud-ui-overlay/manifest.json">
Adds the billing panel:
"settings.billing_panel": "billing-panel.js"
Cloud overlay merges enterprise overlay first, then layers cloud-specific slots on top.
</create>
</task>
</phase>
<phase id="6" name="Release pipelines" location="all repos">
<goal>Reproducible signed delivery per repo.</goal>
<task id="6.1" parallel="false" location="crank-community">
<name>Community release pipeline</name>
<create path=".gitea/workflows/release.yml">
Trigger: push tag v*.
Steps: build admin-api, mcp-server, UI dist; build Docker images; push to the Gitea container registry; assemble checksums + SBOM on the self-hosted runner.
</create>
</task>
<task id="6.2" parallel="false" location="crank-enterprise">
<name>Enterprise release pipeline</name>
<create path=".gitea/workflows/release.yml">
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.
</create>
</task>
<task id="6.3" parallel="false" location="crank-cloud">
<name>Cloud release pipeline + control-plane rollout</name>
<create path=".gitea/workflows/release.yml">
Build cloud-admin-api / cloud-mcp-server / cloud-control-plane; push to managed registry; trigger blue/green rollout in hosted environment.
</create>
</task>
<task id="6.4" parallel="false" location="crank-enterprise">
<name>Unblock live authenticated staging pass</name>
<step>Operator: restore reachability of rmcp.itexp.me:443; provision disposable bootstrap admin creds.</step>
<step>Run full authenticated pass per docs/authenticated-staging-pass.md; record results into docs/staging-regression-notes.md.</step>
</task>
</phase>
<!-- ============================================================== -->
<!-- 7. ACCEPTANCE CRITERIA -->
<!-- ============================================================== -->
<acceptance>
<criterion>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).</criterion>
<criterion>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.</criterion>
<criterion>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.</criterion>
<criterion>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.</criterion>
<criterion>After Phase 4, root-level legacy baseline artifacts are gone or archived; daily work happens only inside the three real working copies.</criterion>
<criterion>After Phase 6, a `git tag v0.x` push to each repo produces signed release artifacts in the correct registry.</criterion>
</acceptance>
<!-- ============================================================== -->
<!-- 8. RISK REGISTER -->
<!-- ============================================================== -->
<risks>
<risk id="R1" severity="high">
<name>Trait granularity wrong</name>
<mitigation>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.</mitigation>
</risk>
<risk id="R2" severity="high">
<name>RegistryExtension migrations applied to wrong database</name>
<mitigation>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.</mitigation>
</risk>
<risk id="R3" severity="medium">
<name>Submodule drift</name>
<mitigation>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.</mitigation>
</risk>
<risk id="R4" severity="medium">
<name>UI slot collisions</name>
<mitigation>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.</mitigation>
</risk>
<risk id="R5" severity="medium">
<name>Test coverage regression during Phase 2 lift</name>
<mitigation>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.</mitigation>
</risk>
<risk id="R6" severity="medium">
<name>Wave 1 trait widening (S4-v2) is a community breaking change</name>
<mitigation>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.</mitigation>
</risk>
<risk id="R7" severity="low">
<name>Diploma artifact desync</name>
<mitigation>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.</mitigation>
</risk>
</risks>
<!-- ============================================================== -->
<!-- 9. CODEX EXECUTION PROTOCOL -->
<!-- ============================================================== -->
<execution_protocol>
<rule>Execute tasks in order within a phase. Tasks with parallel="true" can be batched. Phases are strictly sequential.</rule>
<rule>Each task produces one commit on a feature branch named `feat/modular-decomp-&lt;phase&gt;-&lt;task_id&gt;` (e.g. `feat/modular-decomp-0-3`).</rule>
<rule>Commit message format: `decomp(P&lt;phase&gt;.&lt;task_id&gt;): &lt;task name&gt;`.</rule>
<rule>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.</rule>
<rule>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.</rule>
<rule>Workspace edits to Cargo.toml are sensitive: do not reorder unrelated members. Add new entries at the end of `[workspace] members`.</rule>
<rule>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.</rule>
<rule>Document references in commits using `Refs: docs/modular_decomposition.xml#&lt;phase_id&gt;.&lt;task_id&gt;`.</rule>
<rule>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.</rule>
</execution_protocol>
</plan>