Design Philosophy: - Core is Lego Bricks — export primitives, reduce downstream LOC - Export rules: struct fields yes, mutexes no - Why core/go is minimal (stdlib-only, layers import downward) Known Issues (8): 1. Dual IPC naming (ACTION vs Action) 2. MustServiceFor uses panic (contradicts Result pattern) 3. Embed() legacy accessor (dead code) 4. Package-level vs Core-level logging (document boundary) 5. RegisterAction in wrong file (task.go vs ipc.go) 6. serviceRegistry unexported (should be Lego brick) 7. No c.Process() accessor (planned) 8. NewRuntime/NewWithFactories legacy (verify usage) Co-Authored-By: Virgil <virgil@lethean.io>
18 KiB
CoreGO API Contract — RFC Specification
dappco.re/go/core— Dependency injection, service lifecycle, and message-passing framework. This document is the authoritative API contract. An agent should be able to write a service that registers with Core from this document alone.
Status: Living document
Module: dappco.re/go/core
Version: v0.7.0+
1. Core — The Container
Core is the central application container. Everything registers with Core, communicates through Core, and has its lifecycle managed by Core.
1.1 Creation
c := core.New(
core.WithOption("name", "my-app"),
core.WithService(mypackage.Register),
core.WithService(anotherpackage.Register),
core.WithServiceLock(),
)
c.Run()
core.New() returns *Core (not Result — Core is the one type that can't wrap its own creation error). Functional options are applied in order. WithServiceLock() prevents late service registration.
1.2 Lifecycle
New() → WithService factories called → LockApply()
Run() → ServiceStartup() → Cli.Run() → ServiceShutdown()
Run() is blocking. ServiceStartup calls OnStartup(ctx) on all services implementing Startable. ServiceShutdown calls OnShutdown(ctx) on all Stoppable services. Shutdown uses context.Background() — not the Core context (which is already cancelled).
1.3 Subsystem Accessors
Every subsystem is accessed via a method on Core:
c.Options() // *Options — input configuration
c.App() // *App — application metadata (name, version)
c.Config() // *Config — runtime settings, feature flags
c.Data() // *Data — embedded assets mounted by packages
c.Drive() // *Drive — transport handles (API, MCP, SSH)
c.Fs() // *Fs — filesystem I/O (sandboxable)
c.Cli() // *Cli — CLI command framework
c.IPC() // *Ipc — message bus internals
c.I18n() // *I18n — internationalisation
c.Error() // *ErrorPanic — panic recovery
c.Log() // *ErrorLog — structured logging
c.Context() // context.Context — Core's lifecycle context
c.Env(key) // string — environment variable (cached at init)
2. Primitive Types
2.1 Option
The atom. A single key-value pair.
core.Option{Key: "name", Value: "brain"}
core.Option{Key: "port", Value: 8080}
core.Option{Key: "debug", Value: true}
2.2 Options
A collection of Option with typed accessors.
opts := core.NewOptions(
core.Option{Key: "name", Value: "myapp"},
core.Option{Key: "port", Value: 8080},
core.Option{Key: "debug", Value: true},
)
opts.String("name") // "myapp"
opts.Int("port") // 8080
opts.Bool("debug") // true
opts.Has("name") // true
opts.Len() // 3
opts.Set("name", "new-name")
opts.Get("name") // Result{Value: "new-name", OK: true}
2.3 Result
Universal return type. Every Core operation returns Result.
type Result struct {
Value any
OK bool
}
Usage patterns:
// Check success
r := c.Config().Get("database.host")
if r.OK {
host := r.Value.(string)
}
// Service factory returns Result
func Register(c *core.Core) core.Result {
svc := &MyService{}
return core.Result{Value: svc, OK: true}
}
// Error as Result
return core.Result{Value: err, OK: false}
No generics on Result. Type-assert the Value when needed. This is deliberate — Result is universal across all subsystems without carrying type parameters.
2.4 Message, Query, Task
IPC type aliases — all are any at the type level, distinguished by usage:
type Message any // broadcast via ACTION — fire and forget
type Query any // request/response via QUERY — returns first handler's result
type Task any // work unit via PERFORM — tracked with progress
3. Service System
3.1 Registration
Services register via factory functions passed to WithService:
core.New(
core.WithService(mypackage.Register),
)
The factory signature is func(*Core) Result. The returned Result.Value is the service instance.
3.2 Factory Pattern
func Register(c *core.Core) core.Result {
svc := &MyService{
runtime: core.NewServiceRuntime(c, MyOptions{}),
}
return core.Result{Value: svc, OK: true}
}
NewServiceRuntime[T] gives the service access to Core and typed options:
type MyService struct {
*core.ServiceRuntime[MyOptions]
}
// Access Core from within the service:
func (s *MyService) doSomething() {
c := s.Core()
cfg := s.Config().String("my.setting")
}
3.3 Auto-Discovery
WithService reflects on the returned instance to discover:
- Package name → service name (from reflect type path)
- Startable interface →
OnStartup(ctx) errorcalled duringServiceStartup - Stoppable interface →
OnShutdown(ctx) errorcalled duringServiceShutdown - HandleIPCEvents method → auto-registered as IPC handler
3.4 Retrieval
// Type-safe retrieval
svc, ok := core.ServiceFor[*MyService](c, "mypackage")
if !ok {
// service not registered
}
// Must variant (panics if not found)
svc := core.MustServiceFor[*MyService](c, "mypackage")
// List all registered services
names := c.Services() // []string
3.5 Lifecycle Interfaces
type Startable interface {
OnStartup(ctx context.Context) error
}
type Stoppable interface {
OnShutdown(ctx context.Context) error
}
Services implementing these are automatically called during c.Run().
4. IPC — Message Passing
4.1 ACTION (broadcast)
Fire-and-forget broadcast to all registered handlers:
// Send
c.ACTION(messages.AgentCompleted{
Agent: "codex", Repo: "go-io", Status: "completed",
})
// Register handler
c.RegisterAction(func(c *core.Core, msg core.Message) core.Result {
if ev, ok := msg.(messages.AgentCompleted); ok {
// handle completion
}
return core.Result{OK: true}
})
All handlers receive all messages. Type-switch to filter. Return Result{OK: true} always (errors are logged, not propagated).
4.2 QUERY (request/response)
First handler to return a non-empty result wins:
// Send
result := c.QUERY(MyQuery{Name: "brain"})
if result.OK {
svc := result.Value
}
// Register handler
c.RegisterQuery(func(c *core.Core, q core.Query) core.Result {
if mq, ok := q.(MyQuery); ok {
return core.Result{Value: found, OK: true}
}
return core.Result{OK: false} // not my query
})
4.3 PERFORM (tracked task)
// Execute with progress tracking
c.PERFORM(MyTask{Data: payload})
// Register task handler
c.RegisterTask(func(c *core.Core, t core.Task) core.Result {
// do work, report progress
c.Progress(taskID, 0.5, "halfway done", t)
return core.Result{Value: output, OK: true}
})
5. Config
Runtime configuration with typed accessors and feature flags.
c.Config().Set("database.host", "localhost")
c.Config().Set("database.port", 5432)
host := c.Config().String("database.host") // "localhost"
port := c.Config().Int("database.port") // 5432
// Feature flags
c.Config().Enable("dark-mode")
c.Config().Enabled("dark-mode") // true
c.Config().Disable("dark-mode")
c.Config().EnabledFeatures() // []string
// Type-safe generic getter
val := core.ConfigGet[string](c.Config(), "database.host")
6. Data — Embedded Assets
Mount embedded filesystems and read from them:
//go:embed prompts/*
var promptFS embed.FS
// Mount during service registration
c.Data().New(core.NewOptions(
core.Option{Key: "name", Value: "prompts"},
core.Option{Key: "source", Value: promptFS},
core.Option{Key: "path", Value: "prompts"},
))
// Read
r := c.Data().ReadString("prompts/coding.md")
if r.OK {
content := r.Value.(string)
}
// List
r := c.Data().List("prompts/")
r := c.Data().ListNames("prompts/")
r := c.Data().Mounts() // []string of mount names
7. Drive — Transport Handles
Registry of named transport handles (API endpoints, MCP servers, etc):
c.Drive().New(core.NewOptions(
core.Option{Key: "name", Value: "forge"},
core.Option{Key: "transport", Value: "https://forge.lthn.ai"},
))
r := c.Drive().Get("forge") // Result with DriveHandle
c.Drive().Has("forge") // true
c.Drive().Names() // []string
8. Fs — Filesystem
Sandboxable filesystem I/O. All paths are validated against the root.
fs := c.Fs()
// Read/Write
r := fs.Read("/path/to/file") // Result{Value: string}
r := fs.Write("/path/to/file", content) // Result{OK: bool}
r := fs.WriteMode(path, content, 0600) // With permissions
// Directory ops
r := fs.EnsureDir("/path/to/dir")
r := fs.List("/path/to/dir") // Result{Value: []os.DirEntry}
fs.IsDir(path) // bool
fs.IsFile(path) // bool
fs.Exists(path) // bool
// Streams
r := fs.Open(path) // Result{Value: *os.File}
r := fs.Create(path) // Result{Value: *os.File}
r := fs.Append(path) // Result{Value: io.WriteCloser}
r := fs.ReadStream(path) // Result{Value: io.ReadCloser}
r := fs.WriteStream(path) // Result{Value: io.WriteCloser}
// Delete
r := fs.Delete(path) // single file
r := fs.DeleteAll(path) // recursive
r := fs.Rename(old, new)
r := fs.Stat(path) // Result{Value: os.FileInfo}
9. CLI
Command tree with path-based routing:
c.Command("issue/get", core.Command{
Description: "Get a Forge issue",
Action: s.cmdIssueGet,
})
c.Command("issue/list", core.Command{
Description: "List Forge issues",
Action: s.cmdIssueList,
})
// Action signature
func (s *MyService) cmdIssueGet(opts core.Options) core.Result {
repo := opts.String("_arg") // positional arg
num := opts.String("number") // --number=N flag
// ...
return core.Result{OK: true}
}
Path = command hierarchy. issue/get becomes myapp issue get in CLI.
10. Error Handling
All errors use core.E():
// Standard error
return core.E("service.Method", "what failed", underlyingErr)
// With format
return core.E("service.Method", core.Sprintf("not found: %s", name), nil)
// Error inspection
core.Operation(err) // "service.Method"
core.ErrorMessage(err) // "what failed"
core.ErrorCode(err) // code if set via WrapCode
core.Root(err) // unwrap to root cause
core.Is(err, target) // errors.Is
core.As(err, &target) // errors.As
NEVER use fmt.Errorf, errors.New, or log.*. Core handles all error reporting.
11. Logging
core.Info("server started", "port", 8080)
core.Debug("processing", "item", name)
core.Warn("deprecated", "feature", "old-api")
core.Error("failed", "err", err)
core.Security("access denied", "user", username)
Key-value pairs after the message. Structured, not formatted strings.
12. String Helpers
Core re-exports string operations to avoid strings import:
core.Contains(s, substr)
core.HasPrefix(s, prefix)
core.HasSuffix(s, suffix)
core.TrimPrefix(s, prefix)
core.TrimSuffix(s, suffix)
core.Split(s, sep)
core.SplitN(s, sep, n)
core.Join(sep, parts...)
core.Replace(s, old, new)
core.Lower(s) / core.Upper(s)
core.Trim(s)
core.Sprintf(format, args...)
core.Concat(parts...)
core.NewBuilder() / core.NewReader(s)
13. Path Helpers
core.Path(segments...) // ~/segments joined
core.JoinPath(segments...) // filepath.Join
core.PathBase(p) // filepath.Base
core.PathDir(p) // filepath.Dir
core.PathExt(p) // filepath.Ext
core.PathIsAbs(p) // filepath.IsAbs
core.PathGlob(pattern) // filepath.Glob
core.CleanPath(p, sep) // normalise separators
14. Utility Functions
core.Print(writer, format, args...) // formatted output
core.Env(key) // cached env var (set at init)
core.EnvKeys() // all available env keys
// Arg extraction (positional)
core.Arg(0, args...) // Result
core.ArgString(0, args...) // string
core.ArgInt(0, args...) // int
core.ArgBool(0, args...) // bool
// Flag parsing
core.IsFlag("--name") // true
core.ParseFlag("--name=value") // "name", "value", true
core.FilterArgs(args) // strip flags, keep positional
15. Lock System
Per-Core mutex registry for coordinating concurrent access:
c.Lock("drain").Mutex.Lock()
defer c.Lock("drain").Mutex.Unlock()
// Enable named locks
c.LockEnable("service-registry")
// Apply lock (prevents further registration)
c.LockApply()
16. ServiceRuntime Generic Helper
Embed in services to get Core access and typed options:
type MyService struct {
*core.ServiceRuntime[MyOptions]
}
type MyOptions struct {
BufferSize int
Timeout time.Duration
}
func NewMyService(c *core.Core) core.Result {
svc := &MyService{
ServiceRuntime: core.NewServiceRuntime(c, MyOptions{
BufferSize: 1024,
Timeout: 30 * time.Second,
}),
}
return core.Result{Value: svc, OK: true}
}
// Within the service:
func (s *MyService) DoWork() {
c := s.Core() // access Core
opts := s.Options() // MyOptions{BufferSize: 1024, ...}
cfg := s.Config() // shortcut to s.Core().Config()
}
Design Philosophy
Core Is Lego Bricks
Core is infrastructure, not an encapsulated library. Downstream packages (core/agent, core/mcp, go-process) compose with Core's primitives. Exported fields are intentional, not accidental. Every unexported field that forces a consumer to write a wrapper method adds LOC downstream — the opposite of Core's purpose.
// Core reduces downstream code:
if r.OK { use(r.Value) }
// vs Go convention that adds downstream LOC:
val, err := thing.Get()
if err != nil {
return fmt.Errorf("get: %w", err)
}
This is why core.Result exists — it replaces multiple lines of error handling with if r.OK {}. That's the design: expose the primitive, reduce consumer code.
Export Rules
| Should Export | Why |
|---|---|
| Struct fields used by consumers | Removes accessor boilerplate downstream |
Registry types (serviceRegistry) |
Lets consumers extend service management |
IPC internals (Ipc handlers) |
Lets consumers build custom dispatch |
Lifecycle hooks (OnStart, OnStop) |
Composable without interface overhead |
| Should NOT Export | Why |
|---|---|
| Mutexes and sync primitives | Concurrency must be managed by Core |
| Context/cancel pairs | Lifecycle is Core's responsibility |
| Internal counters | Implementation detail, not a brick |
Why core/go Is Minimal
core/go deliberately avoids importing anything beyond stdlib + go-io + go-log. This keeps it as a near-pure stdlib implementation. Packages that add external dependencies (CLI frameworks, HTTP routers, MCP SDK) live in separate repos:
core/go — pure primitives (stdlib only)
core/go-process — process management (adds os/exec)
core/go-cli — CLI framework (if separated)
core/mcp — MCP server (adds go-sdk)
core/agent — orchestration (adds forge, yaml, mcp)
Each layer imports the one below. core/go imports nothing from the ecosystem — everything imports core/go.
Known Issues
1. Dual IPC Naming
ACTION() and Action() do the same thing. QUERY() and Query(). Two names for one operation. Pick one or document when to use which.
// Currently both exist:
c.ACTION(msg) // uppercase alias
c.Action(msg) // actual implementation
Recommendation: Keep both — ACTION/QUERY/PERFORM are the public "intent" API (semantically loud, used by services). Action/Query/Perform are the implementation methods. Document: services use uppercase, Core internals use lowercase.
2. MustServiceFor Uses Panic
func MustServiceFor[T any](c *Core, name string) T {
panic(...)
}
RFC-025 says "no hidden panics." Must prefix signals it, but the pattern contradicts the Result philosophy. Consider deprecating in favour of ServiceFor + if !ok pattern.
3. Embed() Legacy Accessor
func (c *Core) Embed() Result { return c.data.Get("app") }
Dead accessor with "use Data()" comment. Should be removed — it's API surface clutter that confuses agents.
4. Package-Level vs Core-Level Logging
core.Info("msg") // global default logger
c.Log().Info("msg") // Core's logger instance
Both work. Global functions exist for code without Core access (early init, proc.go helpers). Services with Core access should use c.Log(). Document the boundary.
5. RegisterAction Lives in task.go
IPC registration (RegisterAction, RegisterActions, RegisterTask) is in task.go but the dispatch functions (Action, Query, QueryAll) are in ipc.go. All IPC should be in one file or the split should follow a clear boundary (dispatch vs registration).
6. serviceRegistry Is Unexported
serviceRegistry is unexported, meaning consumers can't extend service management. Per the Lego Bricks philosophy, this should be exported so downstream packages can build on it.
7. No c.Process() Accessor
Process management (go-process) should be a Core subsystem accessor like c.Fs(), not a standalone service retrieved via ServiceFor. Planned for go-process v0.7.0 update.
8. NewRuntime / NewWithFactories — Legacy
These pre-v0.7.0 functions take app any instead of *Core. Verify if they're still used — if not, deprecate.
AX Principles Applied
This API follows RFC-025 Agent Experience (AX):
- Predictable names —
ConfignotCfg,ServicenotSrv - Usage-example comments — every public function shows HOW with real values
- Path is documentation —
c.Data().ReadString("prompts/coding.md") - Universal types — Option, Options, Result everywhere
- Event-driven — ACTION/QUERY/PERFORM, not direct function calls between services
- Tests as spec —
TestFile_Function_{Good,Bad,Ugly}for every function - Export primitives — Core is Lego bricks, not an encapsulated library
Changelog
- 2026-03-25: Initial specification — matches v0.7.0 implementation