diff --git a/go-runtime/compile/schema.go b/go-runtime/compile/schema.go
index bc13d1ea7e..09cc41815e 100644
--- a/go-runtime/compile/schema.go
+++ b/go-runtime/compile/schema.go
@@ -26,17 +26,13 @@ import (
 var (
 	fset = token.NewFileSet()
 
-	ftlPkgPath              = "github.com/TBD54566975/ftl/go-runtime/ftl"
 	ftlCallFuncPath         = "github.com/TBD54566975/ftl/go-runtime/ftl.Call"
 	ftlFSMFuncPath          = "github.com/TBD54566975/ftl/go-runtime/ftl.FSM"
 	ftlTransitionFuncPath   = "github.com/TBD54566975/ftl/go-runtime/ftl.Transition"
 	ftlStartFuncPath        = "github.com/TBD54566975/ftl/go-runtime/ftl.Start"
-	ftlConfigFuncPath       = "github.com/TBD54566975/ftl/go-runtime/ftl.Config"
-	ftlSecretFuncPath       = "github.com/TBD54566975/ftl/go-runtime/ftl.Secret" //nolint:gosec
 	ftlPostgresDBFuncPath   = "github.com/TBD54566975/ftl/go-runtime/ftl.PostgresDatabase"
 	ftlTopicFuncPath        = "github.com/TBD54566975/ftl/go-runtime/ftl.Topic"
 	ftlSubscriptionFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Subscription"
-	ftlTopicHandleTypeName  = "TopicHandle"
 	aliasFieldTag           = "json"
 )
 
@@ -65,15 +61,6 @@ func errorf(node ast.Node, format string, args ...interface{}) *schema.Error {
 	return schema.Errorf(pos, endCol, format, args...)
 }
 
-func tokenWrapf(pos token.Pos, tokenText string, err error, format string, args ...interface{}) *schema.Error {
-	goPos := goPosToSchemaPos(pos)
-	endColumn := goPos.Column
-	if len(tokenText) > 0 {
-		endColumn += utf8.RuneCountInString(tokenText)
-	}
-	return schema.Wrapf(goPos, endColumn, err, format, args...)
-}
-
 //nolint:unparam
 func wrapf(node ast.Node, err error, format string, args ...interface{}) *schema.Error {
 	pos, endCol := goNodePosToSchemaPos(node)
@@ -225,8 +212,6 @@ func extractTopicDecl(pctx *parseContext, node *ast.CallExpr, stack []ast.Node)
 }
 
 func visitCallExpr(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
-	validateCallExpr(pctx, node)
-
 	_, fn := deref[*types.Func](pctx.pkg, node.Fun)
 	if fn == nil {
 		return
@@ -235,10 +220,6 @@ func visitCallExpr(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
 	case ftlCallFuncPath:
 		parseCall(pctx, node, stack)
 
-	case ftlConfigFuncPath, ftlSecretFuncPath:
-		// Secret/config declaration: ftl.Config[<type>](<name>)
-		parseConfigDecl(pctx, node, fn)
-
 	case ftlFSMFuncPath:
 		parseFSMDecl(pctx, node, stack)
 
@@ -253,56 +234,6 @@ func visitCallExpr(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
 	}
 }
 
-// validateCallExpr validates all function calls
-// checks if the function call is:
-// - a direct verb call to an external module
-// - a direct publish call on an external module's topic
-func validateCallExpr(pctx *parseContext, node *ast.CallExpr) {
-	selExpr, ok := node.Fun.(*ast.SelectorExpr)
-	if !ok {
-		return
-	}
-	var lhsIdent *ast.Ident
-	if expr, ok := selExpr.X.(*ast.SelectorExpr); ok {
-		lhsIdent = expr.Sel
-	} else if ident, ok := selExpr.X.(*ast.Ident); ok {
-		lhsIdent = ident
-	} else {
-		return
-	}
-	lhsObject := pctx.pkg.TypesInfo.ObjectOf(lhsIdent)
-	if lhsObject == nil {
-		return
-	}
-	var lhsPkgPath string
-	if pkgName, ok := lhsObject.(*types.PkgName); ok {
-		lhsPkgPath = pkgName.Imported().Path()
-	} else {
-		lhsPkgPath = lhsObject.Pkg().Path()
-	}
-	var lhsIsExternal bool
-	if !pctx.isPathInPkg(lhsPkgPath) {
-		lhsIsExternal = true
-	}
-
-	if lhsType, ok := pctx.pkg.TypesInfo.TypeOf(selExpr.X).(*types.Named); ok && lhsType.Obj().Pkg() != nil && lhsType.Obj().Pkg().Path() == ftlPkgPath {
-		// Calling a function on an FTL type
-		if lhsType.Obj().Name() == ftlTopicHandleTypeName && selExpr.Sel.Name == "Publish" {
-			if lhsIsExternal {
-				pctx.errors.add(errorf(node, "can not publish directly to topics in other modules"))
-			}
-		}
-		return
-	}
-
-	if lhsIsExternal && strings.HasPrefix(lhsPkgPath, "ftl/") {
-		if sig, ok := pctx.pkg.TypesInfo.TypeOf(selExpr.Sel).(*types.Signature); ok && sig.Recv() == nil {
-			// can not call functions in external modules directly
-			pctx.errors.add(errorf(node, "can not call verbs in other modules directly: use ftl.Call(…) instead"))
-		}
-	}
-}
-
 func parseCall(pctx *parseContext, node *ast.CallExpr, stack []ast.Node) {
 	var activeFuncDecl *ast.FuncDecl
 	for i := len(stack) - 1; i >= 0; i-- {
@@ -465,65 +396,6 @@ func parseFSMTransition(pctx *parseContext, node *ast.CallExpr, fn *types.Func,
 	}
 }
 
-func parseConfigDecl(pctx *parseContext, node *ast.CallExpr, fn *types.Func) {
-	name, schemaErr := extractStringLiteralArg(node, 0)
-	if schemaErr != nil {
-		pctx.errors.add(schemaErr)
-		return
-	}
-	if !schema.ValidateName(name) {
-		pctx.errors.add(errorf(node, "config and secret names must be valid identifiers"))
-	}
-
-	index := node.Fun.(*ast.IndexExpr) //nolint:forcetypeassert
-
-	// Type parameter
-	tp := pctx.pkg.TypesInfo.Types[index.Index].Type
-	st, ok := visitType(pctx, index.Index.Pos(), tp, false).Get()
-	if !ok {
-		pctx.errors.add(errorf(index.Index, "unsupported type %q", tp))
-		return
-	}
-
-	// Add the declaration to the module.
-	var decl schema.Decl
-	if fn.FullName() == ftlConfigFuncPath {
-		decl = &schema.Config{
-			Pos:  goPosToSchemaPos(node.Pos()),
-			Name: name,
-			Type: st,
-		}
-	} else {
-		decl = &schema.Secret{
-			Pos:  goPosToSchemaPos(node.Pos()),
-			Name: name,
-			Type: st,
-		}
-	}
-
-	// Check for duplicates
-	_, endCol := goNodePosToSchemaPos(node)
-	for _, d := range pctx.module.Decls {
-		switch fn.FullName() {
-		case ftlConfigFuncPath:
-			c, ok := d.(*schema.Config)
-			if ok && c.Name == name && c.Type.String() == st.String() {
-				pctx.errors.add(errorf(node, "duplicate config declaration at %d:%d-%d", c.Pos.Line, c.Pos.Column, endCol))
-				return
-			}
-		case ftlSecretFuncPath:
-			s, ok := d.(*schema.Secret)
-			if ok && s.Name == name && s.Type.String() == st.String() {
-				pctx.errors.add(errorf(node, "duplicate secret declaration at %d:%d-%d", s.Pos.Line, s.Pos.Column, endCol))
-				return
-			}
-		default:
-		}
-	}
-
-	pctx.module.Decls = append(pctx.module.Decls, decl)
-}
-
 func parseDatabaseDecl(pctx *parseContext, node *ast.CallExpr, dbType string) {
 	name, schemaErr := extractStringLiteralArg(node, 0)
 	if schemaErr != nil {
@@ -832,32 +704,6 @@ func visitStruct(pctx *parseContext, pos token.Pos, tnode types.Type, isExported
 	return optional.Some[*schema.Ref](dataRef)
 }
 
-func visitConst(pctx *parseContext, cnode *types.Const) optional.Option[schema.Value] {
-	if b, ok := cnode.Type().Underlying().(*types.Basic); ok {
-		switch b.Kind() {
-		case types.String:
-			value, err := strconv.Unquote(cnode.Val().String())
-			if err != nil {
-				pctx.errors.add(tokenWrapf(cnode.Pos(), cnode.Val().String(), err, "unsupported string constant"))
-				return optional.None[schema.Value]()
-			}
-			return optional.Some[schema.Value](&schema.StringValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: value})
-
-		case types.Int:
-			value, err := strconv.ParseInt(cnode.Val().String(), 10, 64)
-			if err != nil {
-				pctx.errors.add(tokenWrapf(cnode.Pos(), cnode.Val().String(), err, "unsupported int constant"))
-				return optional.None[schema.Value]()
-			}
-			return optional.Some[schema.Value](&schema.IntValue{Pos: goPosToSchemaPos(cnode.Pos()), Value: int(value)})
-
-		default:
-			return optional.None[schema.Value]()
-		}
-	}
-	return optional.None[schema.Value]()
-}
-
 func visitType(pctx *parseContext, pos token.Pos, tnode types.Type, isExported bool) optional.Option[schema.Type] {
 	if tparam, ok := tnode.(*types.TypeParam); ok {
 		return optional.Some[schema.Type](&schema.Ref{Pos: goPosToSchemaPos(pos), Name: tparam.Obj().Id()})
diff --git a/go-runtime/compile/schema_test.go b/go-runtime/compile/schema_test.go
index 597f6eae0e..153211cff1 100644
--- a/go-runtime/compile/schema_test.go
+++ b/go-runtime/compile/schema_test.go
@@ -543,26 +543,28 @@ func TestErrorReporting(t *testing.T) {
 	r, err := ExtractModuleSchema("testdata/failing", &schema.Schema{})
 	assert.NoError(t, err)
 
+	var actualParent []string
+	var actualChild []string
 	filename := filepath.Join(pwd, `testdata/failing/failing.go`)
 	subFilename := filepath.Join(pwd, `testdata/failing/child/child.go`)
-	actual := slices.Map(r.Errors, func(e *schema.Error) string {
-		str := strings.ReplaceAll(e.Error(), filename+":", "")
-		str = strings.ReplaceAll(str, subFilename+":", "")
-		return str
-	})
-	expected := []string{
-		`6:2-6: unsupported type "uint64" for field "Body"`,
-		`11:2-2: unsupported external type "github.com/TBD54566975/ftl/go-runtime/compile/testdata.lib.NonFTLType"`,
-		`11:2-7: unsupported type "github.com/TBD54566975/ftl/go-runtime/compile/testdata.NonFTLType" for field "Field"`,
+	for _, e := range r.Errors {
+		str := strings.ReplaceAll(e.Error(), subFilename+":", "")
+		str = strings.ReplaceAll(str, filename+":", "")
+		if e.Pos.Filename == filename {
+			actualParent = append(actualParent, str)
+		} else {
+			actualChild = append(actualChild, str)
+		}
+	}
+
+	// failing/failing.go
+	expectedParent := []string{
 		`13:13-34: expected string literal for argument at index 0`,
-		`16:6-41: declared type github.com/blah.lib.NonFTLType in typemap does not match native type github.com/TBD54566975/ftl/go-runtime/compile/testdata.lib.NonFTLType`,
-		`16:18-52: duplicate config declaration at 15:18-52`,
-		`19:18-52: duplicate secret declaration at 18:18-52`,
-		`21:6-6: multiple Go type mappings found for "ftl/failing/child.MultipleMappings"`,
+		`16:18-18: duplicate config declaration for "failing.FTL_ENDPOINT"; already declared at "37:18"`,
+		`19:18-18: duplicate secret declaration for "failing.FTL_ENDPOINT"; already declared at "38:18"`,
 		`22:14-44: duplicate database declaration at 21:14-44`,
 		`25:2-10: unsupported type "error" for field "BadParam"`,
 		`28:2-17: unsupported type "uint64" for field "AnotherBadParam"`,
-		`31:2-13: enum variant "SameVariant" conflicts with existing enum variant of "EnumVariantConflictParent" at "196:2"`,
 		`31:3-3: unexpected directive "ftl:export" attached for verb, did you mean to use '//ftl:verb export' instead?`,
 		`37:36-36: unsupported request type "ftl/failing.Request"`,
 		`37:50-50: unsupported response type "ftl/failing.Response"`,
@@ -593,7 +595,7 @@ func TestErrorReporting(t *testing.T) {
 		`90:3-3: unexpected directive "ftl:verb"`,
 		`99:6-18: "BadValueEnum" is a value enum and cannot be tagged as a variant of type enum "TypeEnum" directly`,
 		`108:6-35: "BadValueEnumOrderDoesntMatter" is a value enum and cannot be tagged as a variant of type enum "TypeEnum" directly`,
-		`124:21-60: config and secret names must be valid identifiers`,
+		`124:21-60: config names must be valid identifiers`,
 		`130:1-1: schema declaration contains conflicting directives`,
 		`130:1-26: only one directive expected when directive "ftl:enum" is present, found multiple`,
 		`152:6-45: enum discriminator "TypeEnum3" cannot contain exported methods`,
@@ -603,9 +605,20 @@ func TestErrorReporting(t *testing.T) {
 		`175:9-26: can not call verbs in other modules directly: use ftl.Call(…) instead`,
 		`180:2-12: struct field unexported must be exported by starting with an uppercase letter`,
 		`184:6-6: unsupported type "ftl/failing/child.BadChildStruct" for field "child"`,
-		`189:6-6: duplicate Data declaration for "failing.Redeclared" in "ftl/failing"; already declared in "ftl/failing/child"`,
+		`189:6-6: duplicate data declaration for "failing.Redeclared"; already declared at "27:6"`,
 	}
-	assert.Equal(t, expected, actual)
+
+	// failing/child/child.go
+	expectedChild := []string{
+		`9:2-6: unsupported type "uint64" for field "Body"`,
+		`14:2-2: unsupported external type "github.com/TBD54566975/ftl/go-runtime/compile/testdata.lib.NonFTLType"`,
+		`14:2-7: unsupported type "github.com/TBD54566975/ftl/go-runtime/compile/testdata.NonFTLType" for field "Field"`,
+		`19:6-41: declared type github.com/blah.lib.NonFTLType in typemap does not match native type github.com/TBD54566975/ftl/go-runtime/compile/testdata.lib.NonFTLType`,
+		`24:6-6: multiple Go type mappings found for "ftl/failing/child.MultipleMappings"`,
+		`34:2-13: enum variant "SameVariant" conflicts with existing enum variant of "EnumVariantConflictParent" at "196:2"`,
+	}
+	assert.Equal(t, expectedParent, actualParent)
+	assert.Equal(t, expectedChild, actualChild)
 }
 
 // Where parsing is correct but validation of the schema fails
diff --git a/go-runtime/compile/testdata/failing/child/child.go b/go-runtime/compile/testdata/failing/child/child.go
index 4e35f68aa0..f435311375 100644
--- a/go-runtime/compile/testdata/failing/child/child.go
+++ b/go-runtime/compile/testdata/failing/child/child.go
@@ -1,6 +1,9 @@
 package child
 
-import lib "github.com/TBD54566975/ftl/go-runtime/compile/testdata"
+import (
+	lib "github.com/TBD54566975/ftl/go-runtime/compile/testdata"
+	"github.com/TBD54566975/ftl/go-runtime/ftl"
+)
 
 type BadChildStruct struct {
 	Body uint64
@@ -30,3 +33,6 @@ type EnumVariantConflictChild int
 const (
 	SameVariant EnumVariantConflictChild = iota
 )
+
+var duplConfig = ftl.Config[string]("FTL_ENDPOINT")
+var duplSecret = ftl.Secret[string]("FTL_ENDPOINT")
diff --git a/go-runtime/compile/testdata/failing/failing.go b/go-runtime/compile/testdata/failing/failing.go
index b802a369e6..e33ed4fd90 100644
--- a/go-runtime/compile/testdata/failing/failing.go
+++ b/go-runtime/compile/testdata/failing/failing.go
@@ -12,11 +12,11 @@ import (
 
 var empty = ftl.Config[string](1)
 
+// var duplConfig = ftl.Config[string]("FTL_ENDPOINT")
 var goodConfig = ftl.Config[string]("FTL_ENDPOINT")
-var duplConfig = ftl.Config[string]("FTL_ENDPOINT")
 
+// var duplSecret = ftl.Secret[string]("FTL_ENDPOINT")
 var goodSecret = ftl.Secret[string]("FTL_ENDPOINT")
-var duplSecret = ftl.Secret[string]("FTL_ENDPOINT")
 
 var goodDB = ftl.PostgresDatabase("testDb")
 var duplDB = ftl.PostgresDatabase("testDb")
diff --git a/go-runtime/schema/call/analyzer.go b/go-runtime/schema/call/analyzer.go
new file mode 100644
index 0000000000..153b7b3127
--- /dev/null
+++ b/go-runtime/schema/call/analyzer.go
@@ -0,0 +1,88 @@
+package call
+
+import (
+	"go/ast"
+	"go/types"
+	"strings"
+
+	"github.com/TBD54566975/ftl/go-runtime/schema/common"
+	"github.com/TBD54566975/golang-tools/go/analysis"
+	"github.com/TBD54566975/golang-tools/go/analysis/passes/inspect"
+	"github.com/TBD54566975/golang-tools/go/ast/inspector"
+)
+
+const (
+	ftlPkgPath             = "github.com/TBD54566975/ftl/go-runtime/ftl"
+	ftlTopicHandleTypeName = "TopicHandle"
+)
+
+// Extractor extracts all function calls.
+var Extractor = common.NewExtractor("validate", (*Fact)(nil), Extract)
+
+type Tag struct{} // Tag uniquely identifies the fact type for this extractor.
+type Fact = common.DefaultFact[Tag]
+
+func Extract(pass *analysis.Pass) (interface{}, error) {
+	//TODO: implement call metadata extraction (for now this just validates all calls)
+
+	in := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) //nolint:forcetypeassert
+	nodeFilter := []ast.Node{
+		(*ast.CallExpr)(nil),
+	}
+	in.Preorder(nodeFilter, func(n ast.Node) {
+		node := n.(*ast.CallExpr) //nolint:forcetypeassert
+		validateCallExpr(pass, node)
+	})
+	return common.NewExtractorResult(pass), nil
+}
+
+// validateCallExpr validates all function calls
+// checks if the function call is:
+// - a direct verb call to an external module
+// - a direct publish call on an external module's topic
+func validateCallExpr(pass *analysis.Pass, node *ast.CallExpr) {
+	selExpr, ok := node.Fun.(*ast.SelectorExpr)
+	if !ok {
+		return
+	}
+	var lhsIdent *ast.Ident
+	if expr, ok := selExpr.X.(*ast.SelectorExpr); ok {
+		lhsIdent = expr.Sel
+	} else if ident, ok := selExpr.X.(*ast.Ident); ok {
+		lhsIdent = ident
+	} else {
+		return
+	}
+	lhsObject := pass.TypesInfo.ObjectOf(lhsIdent)
+	if lhsObject == nil {
+		return
+	}
+	var lhsPkgPath string
+	if pkgName, ok := lhsObject.(*types.PkgName); ok {
+		lhsPkgPath = pkgName.Imported().Path()
+	} else {
+		lhsPkgPath = lhsObject.Pkg().Path()
+	}
+	var lhsIsExternal bool
+	if !common.IsPathInPkg(pass.Pkg, lhsPkgPath) {
+		lhsIsExternal = true
+	}
+
+	if lhsType, ok := pass.TypesInfo.TypeOf(selExpr.X).(*types.Named); ok && lhsType.Obj().Pkg() != nil &&
+		lhsType.Obj().Pkg().Path() == ftlPkgPath {
+		// Calling a function on an FTL type
+		if lhsType.Obj().Name() == ftlTopicHandleTypeName && selExpr.Sel.Name == "Publish" {
+			if lhsIsExternal {
+				common.Errorf(pass, node, "can not publish directly to topics in other modules")
+			}
+		}
+		return
+	}
+
+	if lhsIsExternal && strings.HasPrefix(lhsPkgPath, "ftl/") {
+		if sig, ok := pass.TypesInfo.TypeOf(selExpr.Sel).(*types.Signature); ok && sig.Recv() == nil {
+			// can not call functions in external modules directly
+			common.Errorf(pass, node, "can not call verbs in other modules directly: use ftl.Call(…) instead")
+		}
+	}
+}
diff --git a/go-runtime/schema/common/common.go b/go-runtime/schema/common/common.go
index 377e839035..24fec17a86 100644
--- a/go-runtime/schema/common/common.go
+++ b/go-runtime/schema/common/common.go
@@ -6,6 +6,7 @@ import (
 	"go/token"
 	"go/types"
 	"reflect"
+	"strconv"
 	"strings"
 
 	"github.com/alecthomas/types/optional"
@@ -482,7 +483,54 @@ func GetDeclTypeName(d schema.Decl) string {
 	if lastDotIndex == -1 {
 		return typeStr
 	}
-	return typeStr[lastDotIndex+1:]
+	return strcase.ToLowerCamel(typeStr[lastDotIndex+1:])
+}
+
+func Deref[T types.Object](pass *analysis.Pass, node ast.Expr) (string, T) {
+	var obj T
+	switch node := node.(type) {
+	case *ast.Ident:
+		obj, _ = pass.TypesInfo.Uses[node].(T)
+		return "", obj
+
+	case *ast.SelectorExpr:
+		x, ok := node.X.(*ast.Ident)
+		if !ok {
+			return "", obj
+		}
+		obj, _ = pass.TypesInfo.Uses[node.Sel].(T)
+		return x.Name, obj
+
+	case *ast.IndexExpr:
+		return Deref[T](pass, node.X)
+
+	default:
+		return "", obj
+	}
+}
+
+func ExtractStringLiteralArg(pass *analysis.Pass, node *ast.CallExpr, argIndex int) string {
+	if argIndex >= len(node.Args) {
+		Errorf(pass, node, "expected string argument at index %d", argIndex)
+		return ""
+	}
+
+	literal, ok := node.Args[argIndex].(*ast.BasicLit)
+	if !ok || literal.Kind != token.STRING {
+		Errorf(pass, node, "expected string literal for argument at index %d", argIndex)
+		return ""
+	}
+
+	s, err := strconv.Unquote(literal.Value)
+	if err != nil {
+		Wrapf(pass, node, err, "")
+		return ""
+	}
+	if s == "" {
+		Errorf(pass, node, "expected non-empty string literal for argument at index %d", argIndex)
+		return ""
+	}
+	return s
 }
 
 func isLocalRef(pass *analysis.Pass, ref *schema.Ref) bool {
diff --git a/go-runtime/schema/configsecret/analyzer.go b/go-runtime/schema/configsecret/analyzer.go
new file mode 100644
index 0000000000..2f60064bcf
--- /dev/null
+++ b/go-runtime/schema/configsecret/analyzer.go
@@ -0,0 +1,111 @@
+package configsecret
+
+import (
+	"go/ast"
+	"go/types"
+
+	"github.com/TBD54566975/golang-tools/go/analysis/passes/inspect"
+	"github.com/TBD54566975/golang-tools/go/ast/inspector"
+	"github.com/alecthomas/types/optional"
+
+	"github.com/TBD54566975/ftl/backend/schema"
+	"github.com/TBD54566975/ftl/go-runtime/schema/common"
+	"github.com/TBD54566975/golang-tools/go/analysis"
+)
+
+const (
+	ftlConfigFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Config"
+	ftlSecretFuncPath = "github.com/TBD54566975/ftl/go-runtime/ftl.Secret" //nolint:gosec
+)
+
+// Extractor extracts configs and secrets.
+var Extractor = common.NewExtractor("configsecret", (*Fact)(nil), Extract)
+
+type Tag struct{} // Tag uniquely identifies the fact type for this extractor.
+type Fact = common.DefaultFact[Tag]
+
+func Extract(pass *analysis.Pass) (interface{}, error) {
+	in := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector) //nolint:forcetypeassert
+	nodeFilter := []ast.Node{
+		(*ast.ValueSpec)(nil),
+	}
+	in.Preorder(nodeFilter, func(n ast.Node) {
+		node := n.(*ast.ValueSpec) //nolint:forcetypeassert
+		obj, ok := common.GetObjectForNode(pass.TypesInfo, node).Get()
+		if !ok {
+			return
+		}
+		if len(node.Values) != 1 {
+			return
+		}
+		callExpr, ok := node.Values[0].(*ast.CallExpr)
+		if !ok {
+			return
+		}
+		_, fn := common.Deref[*types.Func](pass, callExpr.Fun)
+		if fn == nil {
+			return
+		}
+		var comments []string
+		if md, ok := common.GetFactForObject[*common.ExtractedMetadata](pass, obj).Get(); ok {
+			comments = md.Comments
+		}
+		var decl optional.Option[schema.Decl]
+		switch fn.FullName() {
+		case ftlSecretFuncPath:
+			decl = extractConfigSecret[*schema.Secret](pass, callExpr, comments)
+		case ftlConfigFuncPath:
+			decl = extractConfigSecret[*schema.Config](pass, callExpr, comments)
+		}
+		if d, ok := decl.Get(); ok {
+			common.MarkSchemaDecl(pass, obj, d)
+		}
+	})
+	return common.NewExtractorResult(pass), nil
+}
+
+func extractConfigSecret[T schema.Decl](
+	pass *analysis.Pass,
+	node *ast.CallExpr,
+	comments []string,
+) optional.Option[schema.Decl] {
+	name := common.ExtractStringLiteralArg(pass, node, 0)
+	if name == "" {
+		return optional.None[schema.Decl]()
+	}
+	var t T
+	if !schema.ValidateName(name) {
+		common.Errorf(pass, node, "%s names must be valid identifiers", common.GetDeclTypeName(t))
+	}
+
+	index := node.Fun.(*ast.IndexExpr) //nolint:forcetypeassert
+	// Type parameter
+	tp := pass.TypesInfo.Types[index.Index].Type
+	st, ok := common.ExtractType(pass, index.Index.Pos(), tp).Get()
+	if !ok {
+		common.Errorf(pass, index.Index, "config is unsupported type %q", tp)
+		return optional.None[schema.Decl]()
+	}
+
+	var decl schema.Decl
+	switch any(t).(type) {
+	case *schema.Config:
+		decl = &schema.Config{
+			Pos:      common.GoPosToSchemaPos(pass.Fset, node.Pos()),
+			Comments: comments,
+			Name:     name,
+			Type:     st,
+		}
+	case *schema.Secret:
+		decl = &schema.Secret{
+			Pos:      common.GoPosToSchemaPos(pass.Fset, node.Pos()),
+			Comments: comments,
+			Name:     name,
+			Type:     st,
+		}
+	default:
+		return optional.None[schema.Decl]()
+	}
+
+	return optional.Some(decl)
+}
diff --git a/go-runtime/schema/enum/analyzer.go b/go-runtime/schema/enum/analyzer.go
index 2a5edb5cf7..cbee770530 100644
--- a/go-runtime/schema/enum/analyzer.go
+++ b/go-runtime/schema/enum/analyzer.go
@@ -81,6 +81,9 @@ func findValueEnumVariants(pass *analysis.Pass, obj types.Object) []*schema.Enum
 
 func validateVariant(pass *analysis.Pass, obj types.Object, variant *schema.EnumVariant) bool {
 	for _, fact := range common.GetAllFacts[*common.ExtractedDecl](pass) {
+		if fact.Decl == nil {
+			continue
+		}
 		existingEnum, ok := fact.Decl.(*schema.Enum)
 		if !ok {
 			continue
diff --git a/go-runtime/schema/extract.go b/go-runtime/schema/extract.go
index 5b4d493185..b155defecb 100644
--- a/go-runtime/schema/extract.go
+++ b/go-runtime/schema/extract.go
@@ -4,11 +4,14 @@ import (
 	"fmt"
 	"go/types"
 
+	"github.com/TBD54566975/ftl/go-runtime/schema/call"
+	"github.com/TBD54566975/ftl/go-runtime/schema/configsecret"
 	"github.com/TBD54566975/ftl/go-runtime/schema/enum"
 	"github.com/TBD54566975/ftl/go-runtime/schema/typeenum"
 	"github.com/TBD54566975/ftl/go-runtime/schema/typeenumvariant"
 	"github.com/TBD54566975/ftl/go-runtime/schema/valueenumvariant"
 	"github.com/alecthomas/types/optional"
+	"github.com/alecthomas/types/tuple"
 	"golang.org/x/exp/maps"
 
 	"github.com/TBD54566975/ftl/backend/schema"
@@ -38,6 +41,7 @@ var Extractors = [][]*analysis.Analyzer{
 	},
 	{
 		metadata.Extractor,
+		call.Extractor,
 	},
 	{
 		// must run before typeenumvariant.Extractor; typeenum.Extractor determines all possible discriminator
@@ -48,6 +52,7 @@ var Extractors = [][]*analysis.Analyzer{
 		typealias.Extractor,
 		verb.Extractor,
 		data.Extractor,
+		configsecret.Extractor,
 		valueenumvariant.Extractor,
 		typeenumvariant.Extractor,
 	},
@@ -135,7 +140,7 @@ func combineAllPackageResults(results map[*analysis.Analyzer][]any, diagnostics
 	refResults := make(map[schema.RefKey]refResult)
 	extractedDecls := make(map[schema.Decl]types.Object)
 	// for identifying duplicates
-	declKeys := make(map[string]types.Object)
+	declKeys := make(map[string]tuple.Pair[types.Object, schema.Position])
 	for _, r := range fResults {
 		fr, ok := r.(finalize.Result)
 		if !ok {
@@ -153,14 +158,24 @@ func combineAllPackageResults(results map[*analysis.Analyzer][]any, diagnostics
 		}
 		copyFailedRefs(refResults, fr.Failed)
 		for decl, obj := range fr.Extracted {
-			if existing, ok := declKeys[decl.String()]; ok && existing != obj {
+			typename := common.GetDeclTypeName(decl)
+			var key string
+			switch d := decl.(type) {
+			case *schema.Config:
+				key = typename + d.Name + ":" + d.Type.String()
+			case *schema.Secret:
+				key = typename + d.Name + ":" + d.Type.String()
+			default:
+				key = typename + d.GetName()
+			}
+			if value, ok := declKeys[key]; ok && value.A != obj {
 				// decls redeclared in subpackage
 				combined.Errors = append(combined.Errors, schema.Errorf(decl.Position(), decl.Position().Column,
-					"duplicate %s declaration for %q in %q; already declared in %q", common.GetDeclTypeName(decl),
-					combined.Module.Name+"."+decl.GetName(), obj.Pkg().Path(), existing.Pkg().Path()))
+					"duplicate %s declaration for %q; already declared at %q", typename,
+					combined.Module.Name+"."+decl.GetName(), value.B))
 				continue
 			}
-			declKeys[decl.String()] = obj
+			declKeys[key] = tuple.Pair[types.Object, schema.Position]{A: obj, B: decl.Position()}
 			extractedDecls[decl] = obj
 		}
 		maps.Copy(combined.NativeNames, fr.NativeNames)