diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7cf33e9a..1ba11bd5 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.10.7] - Unreleased
### Added
+- #1141: New `Production` resource processor that allows managing auto-start and auto-update of interoperability productions.
+- #1141: New `DecomposedProduction` resource processor for managing an interoperability production class constituted from PTD files.
- #408: Modules can now list dependencies without specifying version; will be assumed to be "*"
- #992: Implement automatic history purge logic
- #973: Enables CORS and JWT configuration for WebApplications in module.xml
diff --git a/src/cls/IPM/ResourceProcessor/DecomposedProduction.cls b/src/cls/IPM/ResourceProcessor/DecomposedProduction.cls
new file mode 100644
index 00000000..ceb0e1cc
--- /dev/null
+++ b/src/cls/IPM/ResourceProcessor/DecomposedProduction.cls
@@ -0,0 +1,143 @@
+/// Resource processor for IRIS Interoperability productions stored as decomposed PTD files.
+/// The production class is fully constituted by importing PTD files from the ptd/ subdirectory.
+Class %IPM.ResourceProcessor.DecomposedProduction Extends %IPM.ResourceProcessor.Abstract
+{
+
+Parameter DESCRIPTION As STRING = "Manages an IRIS Interoperability production class constituted from PTD files in the ptd/ subdirectory.";
+
+Parameter ATTRIBUTES As STRING = "Name";
+
+/// The class name of the production to manage.
+Property Name As %String(MAXLEN = "") [ Required ];
+
+ClassMethod IsSupported() As %Boolean
+{
+ quit ##class(%Dictionary.ClassDefinition).%ExistsId("%Studio.SourceControl.Production")
+}
+
+Method OnGetUniqueName(Output pUniqueName)
+{
+ set pUniqueName = ..Name _ ".CLS"
+}
+
+Method OnBeforePhase(
+ pPhase As %String,
+ ByRef pParams) As %Status
+{
+ set sc = $$$OK
+ try {
+ set sc = ##super(pPhase, .pParams)
+ if $$$ISERR(sc) {
+ quit
+ }
+
+ if (pPhase = "Validate") {
+ set sysReqs = ..ResourceReference.Module.SystemRequirements
+ if '$isobject(sysReqs) || (sysReqs.Interoperability '= "enabled") {
+ set sc = $$$ERROR($$$GeneralError, $$$FormatText("Module '%1' must declare Interoperability in SystemRequirements to use the DecomposedProduction resource processor.", ..ResourceReference.Module.Name))
+ quit
+ }
+
+ if '##class(%IPM.ResourceProcessor.DecomposedProduction).IsSupported() {
+ set sc = $$$ERROR($$$GeneralError, "DecomposedProduction resource processor is not supported on this InterSystems IRIS version.")
+ quit
+ }
+ }
+ } catch ex {
+ set sc = ex.AsStatus()
+ }
+ quit sc
+}
+
+Method OnAfterPhase(
+ pPhase As %String,
+ ByRef pParams) As %Status
+{
+ set sc = $$$OK
+ try {
+ set sc = ##super(pPhase, .pParams)
+ if $$$ISERR(sc) {
+ quit
+ }
+
+ if (pPhase = "Compile") {
+ // See %Studio.SourceControl.Production for details on how decomposed productions are represented on the file system
+ set ptdDir = ..ResourceReference.Module.Root _ "ptd/" _ $translate(..Name, ".", "_") _ "/"
+ set sc = ##class(%Studio.SourceControl.Production).ImportPTDsDir(ptdDir)
+ if $$$ISERR(sc) {
+ quit
+ }
+ }
+ } catch ex {
+ set sc = ex.AsStatus()
+ }
+ quit sc
+}
+
+Method OnResolveChildren(
+ ByRef pResourceArray,
+ pCheckModuleOwnership As %Boolean) As %Status
+{
+ // the production class itself should NOT be controlled by another package resource
+ set pResourceArray(..Name _ ".CLS") = ""
+ quit $$$OK
+}
+
+Method OnBeforeArtifact(
+ pExportDirectory As %String,
+ pWorkingDirectory As %String,
+ ByRef pParams) As %Status
+{
+ set sc = $$$OK
+ try {
+ if (pExportDirectory = pWorkingDirectory) {
+ quit
+ }
+
+ if '..ResourceReference.Deploy {
+ set subDir = "ptd/" _ $translate(..Name, ".", "_") _ "/"
+ set sc = ##class(%IPM.Utils.File).CopyDir(
+ pExportDirectory _ subDir,
+ pWorkingDirectory _ subDir)
+ }
+ } catch ex {
+ set sc = ex.AsStatus()
+ }
+ quit sc
+}
+
+Method OnPhase(
+ pPhase As %String,
+ ByRef pParams,
+ Output pResourceHandled As %Boolean = 0) As %Status
+{
+ set sc = $$$OK
+ try {
+ if (pPhase = "Clean") {
+ set pResourceHandled = 1
+
+ set sc = ##class(Ens.Director).GetProductionStatus(.runningName, .state)
+ if $$$ISERR(sc) {
+ quit
+ }
+
+ if (state '= 0) && (runningName = ..Name) {
+ set timeout = ##class(Ens.Director).GetRunningProductionShutdownTimeout()
+ set sc = ##class(Ens.Director).StopProduction(timeout)
+ if $$$ISERR(sc) {
+ quit
+ }
+ }
+
+ set sc = ##class(Ens.Director).DeleteProduction(..Name)
+ if $$$ISERR(sc) {
+ quit
+ }
+ }
+ } catch ex {
+ set sc = ex.AsStatus()
+ }
+ quit sc
+}
+
+}
diff --git a/src/cls/IPM/ResourceProcessor/Production.cls b/src/cls/IPM/ResourceProcessor/Production.cls
new file mode 100644
index 00000000..c53c517b
--- /dev/null
+++ b/src/cls/IPM/ResourceProcessor/Production.cls
@@ -0,0 +1,149 @@
+/// Resource processor for IRIS Interoperability productions.
+/// Manages runtime state: autostart registration, auto-update on activate, and stop on uninstall.
+Class %IPM.ResourceProcessor.Production Extends %IPM.ResourceProcessor.Abstract
+{
+
+Parameter DESCRIPTION As STRING = "Manages the runtime lifecycle of an IRIS Interoperability production: autostart, auto-update on activate, and stopping on uninstall.";
+
+Parameter ATTRIBUTES As STRING = "Name,AutoStart,AutoUpdate";
+
+/// The class name of the production to manage.
+Property Name As %String(MAXLEN = "") [ Required ];
+
+/// If true, registers this production as the namespace autostart production and starts it during Activate.
+Property AutoStart As %Boolean [ InitialExpression = 0 ];
+
+/// If true, queues an update if the production needs updating during Activate.
+Property AutoUpdate As %Boolean [ InitialExpression = 1 ];
+
+Method OnGetUniqueName(Output pUniqueName)
+{
+ // an arbitrary extension chosen not to collide with future document types
+ set pUniqueName = ..Name _ ".ZPRODUCTION"
+}
+
+Method OnBeforePhase(
+ pPhase As %String,
+ ByRef pParams) As %Status
+{
+ set sc = $$$OK
+ try {
+ set sc = ##super(pPhase, .pParams)
+ if $$$ISERR(sc) {
+ quit
+ }
+
+ if (pPhase = "Validate") {
+ set sysReqs = ..ResourceReference.Module.SystemRequirements
+ if '$isobject(sysReqs) || (sysReqs.Interoperability '= "enabled") {
+ set sc = $$$ERROR($$$GeneralError, $$$FormatText("Module '%1' must declare to use the Production resource processor.", ..ResourceReference.Module.Name))
+ quit
+ }
+ }
+ } catch ex {
+ set sc = ex.AsStatus()
+ }
+ quit sc
+}
+
+Method OnAfterPhase(
+ pPhase As %String,
+ ByRef pParams) As %Status
+{
+ set sc = $$$OK
+ try {
+ set sc = ##super(pPhase, .pParams)
+ if $$$ISERR(sc) {
+ quit
+ }
+
+ if (pPhase = "Activate") {
+ if ..AutoStart {
+ // Set this production as the namespace autostart production
+ set sc = ##class(Ens.Director).SetAutoStart(..Name)
+ if $$$ISERR(sc) {
+ quit
+ }
+
+ set sc = ##class(Ens.Director).GetProductionStatus(.runningName, .state)
+ if $$$ISERR(sc) {
+ quit
+ }
+
+ if state = 0 {
+ // StartProduction issues transaction control statements that break
+ // IPM's outer transaction, so run it in a worker job
+ set workmgr = $system.WorkMgr.%New()
+ if workmgr = "" {
+ set sc = %objlasterror
+ quit
+ }
+ set sc = workmgr.Queue("##class(Ens.Director).StartProduction", ..Name)
+ if $$$ISERR(sc) {
+ quit
+ }
+ set sc = workmgr.Sync()
+ if $$$ISERR(sc) {
+ quit
+ }
+ }
+ }
+
+ if ..AutoUpdate {
+ if ##class(Ens.Director).ProductionNeedsUpdate() {
+ set timeout = ##class(Ens.Director).GetRunningProductionUpdateTimeout()
+ set workmgr = $system.WorkMgr.%New()
+ if workmgr = "" {
+ set sc = %objlasterror
+ quit
+ }
+ set sc = workmgr.Queue("##class(Ens.Director).UpdateProduction", timeout)
+ if $$$ISERR(sc) {
+ quit
+ }
+ }
+ }
+ }
+ } catch ex {
+ set sc = ex.AsStatus()
+ }
+ quit sc
+}
+
+Method OnPhase(
+ pPhase As %String,
+ ByRef pParams,
+ Output pResourceHandled As %Boolean = 0) As %Status
+{
+ set sc = $$$OK
+ try {
+ if (pPhase = "Clean") {
+ set pResourceHandled = 1
+
+ set sc = ##class(Ens.Director).GetProductionStatus(.runningName, .state)
+ if $$$ISERR(sc) {
+ quit
+ }
+
+ if (state '= 0) && (runningName = ..Name) {
+ set timeout = ##class(Ens.Director).GetRunningProductionShutdownTimeout()
+ set sc = ##class(Ens.Director).StopProduction(timeout)
+ if $$$ISERR(sc) {
+ quit
+ }
+ }
+
+ if ..AutoStart {
+ set sc = ##class(Ens.Director).SetAutoStart("")
+ if $$$ISERR(sc) {
+ quit
+ }
+ }
+ }
+ } catch ex {
+ set sc = ex.AsStatus()
+ }
+ quit sc
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/DecomposedProduction.cls b/tests/integration_tests/Test/PM/Integration/DecomposedProduction.cls
new file mode 100644
index 00000000..5600cde1
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/DecomposedProduction.cls
@@ -0,0 +1,100 @@
+Class Test.PM.Integration.DecomposedProduction Extends Test.PM.Integration.Base
+{
+
+Parameter NEEDSREGISTRY As BOOLEAN = 0;
+
+Method OnBeforeAllTests() As %Status
+{
+ set status = ##class(%IPM.Main).Shell("repo -n integration-tests -fs -path /home/irisowner/zpm/tests/integration_tests/Test/PM/Integration/_data/")
+ $$$ThrowOnError(status)
+ if '##class(%IPM.Storage.SystemRequirements).IsInteroperabilityEnabled() {
+ set status = ##class(%EnsembleMgr).EnableNamespace($namespace, 1)
+ $$$ThrowOnError(status)
+ }
+ quit $$$OK
+}
+
+Method OnBeforeOneTest(testname As %String) As %Status
+{
+ if '##class(%IPM.ResourceProcessor.DecomposedProduction).IsSupported() {
+ do $$$AssertSkipped("DecomposedProduction requires %Studio.SourceControl.Production, which is not available on this IRIS version.")
+ }
+ quit $$$OK
+}
+
+Method TestLoadFromDirectory()
+{
+ set status = $$$OK
+ try {
+ set moduleDir = ..GetModuleDir("decomposed-production")
+ set status = ##class(%IPM.Main).Shell("load -verbose " _ moduleDir)
+ do $$$AssertStatusOK(status, "Loaded module-a from directory.")
+
+ do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("Sample.NewProduction"), "Production class Sample.NewProduction exists after load.")
+
+ set prodConfig = ##class(Ens.Config.Production).%OpenId("Sample.NewProduction", , .openSC)
+ do $$$AssertStatusOK(openSC, "Ens.Config.Production record for Sample.NewProduction is accessible.")
+ do $$$AssertTrue($isobject(prodConfig), "Ens.Config.Production object is valid.")
+
+ set status = ##class(%IPM.Main).Shell("uninstall module-a -r")
+ do $$$AssertStatusOK(status, "Uninstalled module-a.")
+
+ do $$$AssertTrue('##class(%Dictionary.ClassDefinition).%ExistsId("Sample.NewProduction"), "Production class Sample.NewProduction removed after uninstall.")
+ } catch ex {
+ do $$$AssertStatusOK(ex.AsStatus(), "An exception occurred in TestLoadFromDirectory.")
+ }
+}
+
+Method TestAutoStart()
+{
+ set status = $$$OK
+ try {
+ set moduleDir = ..GetModuleDir("decomposed-production")
+ set status = ##class(%IPM.Main).Shell("load -verbose " _ moduleDir)
+ do $$$AssertStatusOK(status, "Loaded module-a from directory.")
+
+ do $$$AssertEquals($get(^Ens.AutoStart), "Sample.NewProduction")
+
+ set status = ##class(%IPM.Main).Shell("uninstall module-a -r")
+ do $$$AssertStatusOK(status, "Uninstalled module-a.")
+
+ do $$$AssertEquals($get(^Ens.AutoStart), "")
+ } catch ex {
+ do $$$AssertStatusOK(ex.AsStatus(), "An exception occurred in TestAutoStart.")
+ }
+}
+
+Method TestPackagePublishInstall()
+{
+ set status = $$$OK
+ try {
+ set status = ##class(%IPM.Main).Shell("repo -o -name zot -url http://oras:5000")
+ do $$$AssertStatusOK(status, "Configured Zot registry.")
+
+ set moduleDir = ..GetModuleDir("decomposed-production")
+ set status = ##class(%IPM.Main).Shell("load -verbose " _ moduleDir)
+ do $$$AssertStatusOK(status, "Loaded module-a from directory.")
+
+ set status = ##class(%IPM.Main).Shell("publish module-a -r zot -verbose")
+ do $$$AssertStatusOK(status, "Published module-a to Zot registry.")
+
+ set status = ##class(%IPM.Main).Shell("uninstall module-a -r")
+ do $$$AssertStatusOK(status, "Uninstalled module-a before reinstall from registry.")
+
+ do $$$AssertNotTrue(##class(%Dictionary.ClassDefinition).%ExistsId("Sample.NewProduction"), "Production class removed after uninstall.")
+
+ set status = ##class(%IPM.Main).Shell("install module-a -verbose")
+ do $$$AssertStatusOK(status, "Installed module-a from Zot registry.")
+
+ do $$$AssertTrue(##class(%Dictionary.ClassDefinition).%ExistsId("Sample.NewProduction"), "Production class Sample.NewProduction exists after install from registry.")
+
+ set status = ##class(%IPM.Main).Shell("uninstall module-a -r")
+ do $$$AssertStatusOK(status, "Final uninstall of module-a.")
+ } catch ex {
+ do $$$AssertStatusOK(ex.AsStatus(), "An exception occurred in TestPackagePublishInstall.")
+ }
+ // Always clean up the Zot repo config
+ do ##class(%IPM.Main).Shell("repo -n zot -delete")
+}
+
+}
diff --git a/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/module.xml b/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/module.xml
new file mode 100644
index 00000000..305be3be
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/module.xml
@@ -0,0 +1,12 @@
+
+
+
+
+ module-a
+ 1.0.0
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/ProdStgs-Sample_NewProduction.xml b/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/ProdStgs-Sample_NewProduction.xml
new file mode 100644
index 00000000..e06f1ca0
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/ProdStgs-Sample_NewProduction.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+Sample.NewProduction
+1841-01-01 00:00:00.000
+
+
+
+
+ProductionSettings-Sample_NewProduction
+- ProductionSettings:Sample.NewProduction.PTD
+
+
+
+
+]]>
+
+
+
+2
+]]>
+
diff --git a/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/Stgs-and another one38E0.xml b/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/Stgs-and another one38E0.xml
new file mode 100644
index 00000000..078f8933
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/Stgs-and another one38E0.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+Sample.NewProduction
+1841-01-01 00:00:00.000
+
+
+
+
+Settings-and another one
+- Settings:and another one.PTD
+
+
+
+
+]]>
+
+
+
+
+]]>
+
diff --git a/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/Stgs-hello5480.xml b/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/Stgs-hello5480.xml
new file mode 100644
index 00000000..1b329ec8
--- /dev/null
+++ b/tests/integration_tests/Test/PM/Integration/_data/decomposed-production/ptd/Sample_NewProduction/Stgs-hello5480.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+Sample.NewProduction
+1841-01-01 00:00:00.000
+
+
+
+
+Settings-hello
+- Settings:hello.PTD
+
+
+
+
+]]>
+
+
+
+
+]]>
+