--- module: dappco.re/go/core repo: core/go lang: go tier: lib tags: - framework - container - dependency-injection - lifecycle - service-runtime --- # CoreGO RFC — API Contract > `dappco.re/go/core` — Dependency injection, service lifecycle, permission, 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.8.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 ```go 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() RunE() → defer ServiceShutdown() → ServiceStartup() → Cli.Run() → returns error Run() → RunE() → os.Exit(1) on error ``` `RunE()` is the primary lifecycle — returns `error`, always calls `ServiceShutdown` via defer (even on startup failure or panic). `Run()` is sugar that calls `RunE()` and exits on error. `ServiceStartup` calls `OnStartup(ctx)` on all `Startable` services in registration order. `ServiceShutdown` calls `OnShutdown(ctx)` on all `Stoppable` services. ### 1.3 Subsystem Accessors Every subsystem is accessed via a method on Core: ```go c.Options() // *Options — input configuration c.App() // *App — application metadata (name, version) c.Config() // *Config — runtime settings, feature flags c.Data() // *Data — embedded assets (Registry[*Embed]) c.Drive() // *Drive — transport handles (Registry[*DriveHandle]) 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.Process() // *Process — managed execution (Action sugar) c.API() // *API — remote streams (protocol handlers) c.Action(name) // *Action — named callable (register/invoke) c.Task(name) // *Task — composed Action sequence c.Entitled(name) // Entitlement — permission check c.RegistryOf(n) // *Registry — cross-cutting queries c.Context() // context.Context c.Env(key) // string — environment variable (cached at init) ``` --- ## 2. Primitive Types ### 2.1 Option The atom. A single key-value pair. ```go 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. ```go 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. ```go type Result struct { Value any OK bool } ``` Usage patterns: ```go // 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 IPC type aliases for the broadcast/request system: ```go type Message any // broadcast via ACTION — fire and forget type Query any // request/response via QUERY — returns first handler's result ``` For tracked work, use named Actions: `c.PerformAsync("action.name", opts)`. --- ## 3. Service System ### 3.1 Registration Services register via factory functions passed to `WithService`: ```go 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 ```go 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: ```go 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) Result` called during `ServiceStartup` - **Stoppable interface** → `OnShutdown(ctx) Result` called during `ServiceShutdown` - **HandleIPCEvents method** → auto-registered as IPC handler ### 3.4 Retrieval ```go // 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 in registration order ``` ### 3.5 Lifecycle Interfaces ```go type Startable interface { OnStartup(ctx context.Context) Result } type Stoppable interface { OnShutdown(ctx context.Context) Result } ``` Services implementing these are called during `RunE()` / `Run()` in registration order. --- ## 4. IPC — Message Passing ### 4.1 ACTION (broadcast) Fire-and-forget broadcast to all registered handlers: ```go // 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. Handler return values are ignored — broadcast calls ALL handlers regardless. Each handler is wrapped in panic recovery. ### 4.2 QUERY (request/response) First handler to return a non-empty result wins: ```go // 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 PerformAsync (background action) ```go // Execute a named action in background with progress tracking r := c.PerformAsync("agentic.dispatch", opts) taskID := r.Value.(string) // Report progress c.Progress(taskID, 0.5, "halfway done", "agentic.dispatch") ``` Broadcasts `ActionTaskStarted`, `ActionTaskProgress`, `ActionTaskCompleted` as ACTION messages. --- ## 5. Config Runtime configuration with typed accessors and feature flags. ```go 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 //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 (insertion order) // Data embeds Registry[*Embed] — all Registry methods available: c.Data().Has("prompts") c.Data().Each(func(name string, emb *Embed) { ... }) ``` --- ## 7. Drive — Transport Handles Registry of named transport handles (API endpoints, MCP servers, etc): ```go 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 (insertion order) // Drive embeds Registry[*DriveHandle] — all Registry methods available. ``` --- ## 8. Fs — Filesystem Sandboxable filesystem I/O. All paths are validated against the root. ```go 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} // Atomic write (write-to-temp-then-rename, safe for concurrent readers) r := fs.WriteAtomic(path, content) // 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} // Sandbox control fs.Root() // sandbox root path fs.NewUnrestricted() // Fs with root "/" — full access ``` --- ## 9. CLI Command tree with path-based routing: ```go 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. Managed commands have lifecycle provided by go-process: ```go c.Command("serve", core.Command{ Action: handler, Managed: "process.daemon", // go-process provides start/stop/restart }) ``` --- ## 10. Error Handling All errors use `core.E()`: ```go // 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 ```go 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: ```go 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 ```go 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 ```go 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 // Identifiers and validation core.ID() // "id-42-a3f2b1" — unique per process core.ValidateName("brain") // Result{OK: true} — rejects "", ".", "..", path seps core.SanitisePath("../../x") // "x" — extracts safe base, "invalid" for dangerous // JSON (wraps encoding/json — consumers don't import it directly) core.JSONMarshal(myStruct) // Result{Value: []byte, OK: bool} core.JSONMarshalString(myStruct) // string (returns "{}" on error) core.JSONUnmarshal(data, &target) // Result{OK: bool} core.JSONUnmarshalString(s, &target) ``` --- ## 15. Lock System Per-Core mutex registry for coordinating concurrent access: ```go 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: ```go 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() } ``` --- ## 17. Process — Managed Execution `c.Process()` is sugar over named Actions. core/go defines the primitive. go-process provides the implementation via `c.Action("process.run", handler)`. ```go // Synchronous — returns Result r := c.Process().Run(ctx, "git", "log", "--oneline") r := c.Process().RunIn(ctx, "/repo", "go", "test", "./...") r := c.Process().RunWithEnv(ctx, dir, []string{"GOWORK=off"}, "go", "test") // Async — returns process ID r := c.Process().Start(ctx, opts) // Control c.Process().Kill(ctx, core.NewOptions(core.Option{Key: "id", Value: processID})) // Capability check if c.Process().Exists() { /* go-process is registered */ } ``` **Permission by registration:** No go-process registered → `c.Process().Run()` returns `Result{OK: false}`. No config, no tokens. The service either exists or it doesn't. ```go // Sandboxed Core — no process capability c := core.New() c.Process().Run(ctx, "rm", "-rf", "/") // Result{OK: false} — nothing happens // Full Core — process registered c := core.New(core.WithService(process.Register)) c.Process().Run(ctx, "git", "log") // executes, returns output ``` > Consumer implementation: see `go-process/docs/RFC.md` --- ## 18. Action and Task — The Execution Primitives An Action is a named, registered callable. A Task is a composed sequence of Actions. ### 18.1 Action — The Atomic Unit ```go // Register c.Action("git.log", func(ctx context.Context, opts core.Options) core.Result { dir := opts.String("dir") return c.Process().RunIn(ctx, dir, "git", "log", "--oneline") }) // Invoke r := c.Action("git.log").Run(ctx, core.NewOptions( core.Option{Key: "dir", Value: "/repo"}, )) // Check capability c.Action("process.run").Exists() // true if go-process registered // List all c.Actions() // []string{"process.run", "agentic.dispatch", ...} ``` `c.Action(name)` is dual-purpose: with handler arg → register; without → return for invocation. ### 18.2 Action Type ```go type ActionHandler func(context.Context, Options) Result type Action struct { Name string Handler ActionHandler Description string Schema Options // expected input keys } ``` `Action.Run()` has panic recovery and entitlement checking (Section 21) built in. ### 18.3 Where Actions Come From Services register during `OnStartup`: ```go func (s *MyService) OnStartup(ctx context.Context) core.Result { c := s.Core() c.Action("process.run", s.handleRun) c.Action("git.clone", s.handleGitClone) return core.Result{OK: true} } ``` The action namespace IS the capability map. go-process registers `process.*`, core/agent registers `agentic.*`. ### 18.4 Permission Model Three states for any action: | State | `Exists()` | `Entitled()` | `Run()` | |-------|-----------|-------------|---------| | Not registered | false | — | `Result{OK: false}` not registered | | Registered, not entitled | true | false | `Result{OK: false}` not entitled | | Registered and entitled | true | true | executes handler | ### 18.5 Task — Composing Actions ```go c.Task("deploy", core.Task{ Description: "Build, test, deploy", Steps: []core.Step{ {Action: "go.build"}, {Action: "go.test"}, {Action: "docker.push"}, {Action: "ansible.deploy", Async: true}, // doesn't block }, }) r := c.Task("deploy").Run(ctx, c, opts) ``` Sequential steps stop on first failure. `Async: true` steps fire without blocking. `Input: "previous"` pipes last step's output to next step. ### 18.6 Background Execution ```go r := c.PerformAsync("agentic.dispatch", opts) taskID := r.Value.(string) // Broadcasts ActionTaskStarted, ActionTaskProgress, ActionTaskCompleted c.Progress(taskID, 0.5, "halfway", "agentic.dispatch") ``` ### 18.7 How Process Fits `c.Process()` is sugar over Actions: ```go c.Process().Run(ctx, "git", "log") // equivalent to: c.Action("process.run").Run(ctx, core.NewOptions( core.Option{Key: "command", Value: "git"}, core.Option{Key: "args", Value: []string{"log"}}, )) ``` --- ## 19. API — Remote Streams Drive is the phone book (WHERE). API is the phone (HOW). Consumer packages register protocol handlers. ```go // Configure endpoint in Drive c.Drive().New(core.NewOptions( core.Option{Key: "name", Value: "charon"}, core.Option{Key: "transport", Value: "http://10.69.69.165:9101/mcp"}, )) // Open stream — looks up Drive, finds protocol handler r := c.API().Stream("charon") if r.OK { stream := r.Value.(core.Stream) stream.Send(payload) resp, _ := stream.Receive() stream.Close() } ``` ### 19.1 Stream Interface ```go type Stream interface { Send(data []byte) error Receive() ([]byte, error) Close() error } ``` ### 19.2 Protocol Handlers Consumer packages register factories per URL scheme: ```go // In a transport package's OnStartup: c.API().RegisterProtocol("http", httpStreamFactory) c.API().RegisterProtocol("mcp", mcpStreamFactory) ``` Resolution: `c.API().Stream("charon")` → Drive lookup → extract scheme → find factory → create Stream. No protocol handler = no capability. ### 19.3 Remote Action Dispatch Actions transparently cross machine boundaries via `host:action` syntax: ```go // Local r := c.RemoteAction("agentic.status", ctx, opts) // Remote — same API, different host r := c.RemoteAction("charon:agentic.status", ctx, opts) // → splits on ":" → endpoint="charon", action="agentic.status" // → c.API().Call("charon", "agentic.status", opts) // Web3 — Lethean dVPN routed r := c.RemoteAction("snider.lthn:brain.recall", ctx, opts) ``` ### 19.4 Direct Call ```go r := c.API().Call("charon", "agentic.dispatch", opts) // Opens stream, sends JSON-RPC, receives response, closes stream ``` --- ## 20. Registry — The Universal Collection Primitive Thread-safe named collection. The brick all registries build on. ### 20.1 The Type ```go // Registry is a thread-safe named collection. The universal brick // for all named registries in Core. type Registry[T any] struct { items map[string]T mu sync.RWMutex locked bool } ``` ### 20.3 Operations ```go r := core.NewRegistry[*Service]() r.Set("brain", brainSvc) // register r.Get("brain") // Result{brainSvc, true} r.Has("brain") // true r.Names() // []string{"brain", "monitor", ...} r.List("brain.*") // glob/prefix match r.Each(func(name string, item T)) // iterate r.Len() // count r.Lock() // prevent further Set calls r.Locked() // bool r.Delete("brain") // remove (if not locked) ``` ### 20.4 Core Accessor `c.Registry(name)` accesses named registries. Each subsystem's registry is accessible through it: ```go c.RegistryOf("services") // the service registry c.Registry("commands") // the command tree c.RegistryOf("actions") // IPC action handlers c.RegistryOf("drives") // transport handles c.Registry("data") // mounted filesystems ``` Cross-cutting queries become natural: ```go c.RegistryOf("actions").List("process.*") // all process capabilities c.RegistryOf("drives").Names() // all configured transports c.RegistryOf("services").Has("brain") // is brain service loaded? c.RegistryOf("actions").Len() // how many actions registered? ``` ### 20.5 Typed Accessors Are Sugar The existing subsystem accessors become typed convenience over Registry: ```go // These are equivalent: c.Service("brain") // typed sugar c.RegistryOf("services").Get("brain") // universal access c.Drive().Get("forge") // typed sugar c.RegistryOf("drives").Get("forge") // universal access c.Action("process.run") // typed sugar c.RegistryOf("actions").Get("process.run") // universal access ``` The typed accessors stay — they're ergonomic and type-safe. `c.Registry()` adds the universal query layer on top. ### 20.6 What Embeds Registry All named collections in Core embed `Registry[T]`: - `ServiceRegistry` → `Registry[*Service]` - `CommandRegistry` → `Registry[*Command]` - `Drive` → `Registry[*DriveHandle]` - `Data` → `Registry[*Embed]` - `Lock.locks` → `Registry[*sync.RWMutex]` - `IPC.actions` → `Registry[*Action]` - `IPC.tasks` → `Registry[*Task]` --- ## 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. ```go // 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 | ### DTO Pattern — Structs Not Props Exported functions that handle Actions MUST accept and return typed structs, never loose key-value params. The struct IS the DTO — CoreTS and CorePHP generate their typed clients from Go struct definitions. ```go // CORRECT — struct defines the contract type CreateOrderInput struct { ProductID string `json:"product_id"` Quantity int `json:"quantity"` Currency string `json:"currency"` } type CreateOrderOutput struct { OrderID string `json:"order_id"` Total int64 `json:"total"` } func (s *CommerceService) CreateOrder(ctx context.Context, in CreateOrderInput) (CreateOrderOutput, error) { // ... } // WRONG — loose Options, no type contract func (s *CommerceService) CreateOrder(ctx context.Context, opts core.Options) core.Result { productID := opts.String("product_id") // invisible to codegen } ``` The generation pipeline: ``` Go struct (tagged with json) → CoreCommand reads struct metadata via reflection → OpenAPI spec generated (JSON Schema per struct) → CoreTS: TypeScript interfaces + typed fetch calls → CorePHP: PHP DTOs + typed Action calls ``` Each package defines its own input/output structs in its source. The structs live next to the handler — no separate DTO layer. The SDK pipeline reads them at build time. **core.Options is for framework internals** (service registration, config). **Typed structs are for business logic** (actions, commands, API endpoints). This separation is what makes codegen possible. ### 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/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. ## Consumer RFCs core/go provides the primitives. These RFCs describe how consumers use them: | Package | RFC | Scope | |---------|-----|-------| | go-process | `core/go-process/docs/RFC.md` | Action handlers for process.run/start/kill, ManagedProcess, daemon registry | | core/agent | `code/core/agent/docs/RFC.md` | Named Actions, completion pipeline (P6-1 fix), WriteAtomic migration, Process migration, Entitlement gating | Each consumer RFC is self-contained — an agent can implement it from the document alone. --- ## Versioning ### Release Model The patch count after a release IS the quality metric. v0.8.1 means the spec missed one thing. ### Cadence 1. **RFC spec** — design the version in prose 2. **Implement** — build to spec with AX-7 tests 3. **Refine** — review passes catch drift 4. **Tag** — when all sections pass 5. **Measure** — patches tell you what was missed ## 21. Entitlement — The Permission Primitive Core provides the primitive. go-entitlements and commerce-matrix provide implementations. ### 21.1 The Problem `*Core` grants God Mode (P11-1). Every service sees everything. The 14 findings in Root Cause 2 all stem from this. The conclave is trusted — but the SaaS platform (RFC-004), the commerce hierarchy (RFC-005), and the agent sandbox all need boundaries. Three systems ask the same question with different vocabulary: ``` Can [subject] do [action] with [quantity] in [context]? ``` | System | Subject | Action | Quantity | Context | |--------|---------|--------|----------|---------| | RFC-004 Entitlements | workspace | feature.code | N | active packages | | RFC-005 Commerce Matrix | entity (M1/M2/M3) | permission.key | 1 | hierarchy path | | Core Actions | this Core instance | action.name | 1 | registered services | ### 21.2 The Primitive ```go // Entitlement is the result of a permission check. // Carries context for both boolean gates (Allowed) and usage limits (Limit/Used/Remaining). // Maps directly to RFC-004 EntitlementResult and RFC-005 PermissionResult. type Entitlement struct { Allowed bool // permission granted Unlimited bool // no cap (agency tier, admin, trusted conclave) Limit int // total allowed (0 = boolean gate, no quantity dimension) Used int // current consumption Remaining int // Limit - Used Reason string // denial reason — for UI feedback and audit logging } // Entitled checks if an action is permitted in the current context. // Default: always returns Allowed=true, Unlimited=true (trusted conclave). // With go-entitlements: checks workspace packages, features, usage, boosts. // With commerce-matrix: checks entity hierarchy, lock cascade. // // e := c.Entitled("process.run") // boolean — can this Core run processes? // e := c.Entitled("social.accounts", 3) // quantity — can workspace create 3 more accounts? // if e.Allowed { proceed() } // if e.NearLimit(0.8) { showWarning() } func (c *Core) Entitled(action string, quantity ...int) Entitlement ``` ### 21.3 The Checker — Consumer-Provided Core defines the interface. Consumer packages provide the implementation. ```go // EntitlementChecker answers "can [subject] do [action] with [quantity]?" // Subject comes from context (workspace, entity, user — consumer's concern). type EntitlementChecker func(action string, quantity int, ctx context.Context) Entitlement ``` Registration via Core: ```go // SetEntitlementChecker replaces the default (permissive) checker. // Called by go-entitlements or commerce-matrix during OnStartup. // // func (s *EntitlementService) OnStartup(ctx context.Context) core.Result { // s.Core().SetEntitlementChecker(s.check) // return core.Result{OK: true} // } func (c *Core) SetEntitlementChecker(checker EntitlementChecker) ``` Default checker (no entitlements package loaded): ```go // defaultChecker — trusted conclave, everything permitted func defaultChecker(action string, quantity int, ctx context.Context) Entitlement { return Entitlement{Allowed: true, Unlimited: true} } ``` ### 21.4 Enforcement Point — Action.Run() The entitlement check lives in `Action.Run()`, before execution. One enforcement point for all capabilities. ```go func (a *Action) Run(ctx context.Context, opts Options) (result Result) { if !a.Exists() { return not-registered } if !a.enabled { return disabled } // Entitlement check — permission boundary if e := a.core.Entitled(a.Name); !e.Allowed { return Result{E("action.Run", Concat("not entitled: ", a.Name, " — ", e.Reason), nil), false} } defer func() { /* panic recovery */ }() return a.Handler(ctx, opts) } ``` Three states for any action: | State | Exists() | Entitled() | Run() | |-------|----------|------------|-------| | Not registered | false | — | Result{OK: false} "not registered" | | Registered, not entitled | true | false | Result{OK: false} "not entitled" | | Registered and entitled | true | true | executes handler | ### 21.5 How RFC-004 (SaaS Entitlements) Plugs In go-entitlements registers as a service and replaces the checker: ```go // In go-entitlements: func (s *Service) OnStartup(ctx context.Context) core.Result { s.Core().SetEntitlementChecker(func(action string, qty int, ctx context.Context) core.Entitlement { workspace := s.workspaceFromContext(ctx) if workspace == nil { return core.Entitlement{Allowed: true, Unlimited: true} // no workspace = system context } result := s.Can(workspace, action, qty) return core.Entitlement{ Allowed: result.IsAllowed(), Unlimited: result.IsUnlimited(), Limit: result.Limit, Used: result.Used, Remaining: result.Remaining, Reason: result.Message(), } }) return core.Result{OK: true} } ``` Maps 1:1 to RFC-004's `EntitlementResult`: - `$result->isAllowed()` → `e.Allowed` - `$result->isUnlimited()` → `e.Unlimited` - `$result->limit` → `e.Limit` - `$result->used` → `e.Used` - `$result->remaining` → `e.Remaining` - `$result->getMessage()` → `e.Reason` - `$result->isNearLimit()` → `e.NearLimit(0.8)` - `$result->getUsagePercentage()` → `e.UsagePercent()` ### 21.6 How RFC-005 (Commerce Matrix) Plugs In commerce-matrix registers and replaces the checker with hierarchy-aware logic: ```go // In commerce-matrix: func (s *MatrixService) OnStartup(ctx context.Context) core.Result { s.Core().SetEntitlementChecker(func(action string, qty int, ctx context.Context) core.Entitlement { entity := s.entityFromContext(ctx) if entity == nil { return core.Entitlement{Allowed: true, Unlimited: true} } result := s.Can(entity, action, "") return core.Entitlement{ Allowed: result.IsAllowed(), Reason: result.Reason, } }) return core.Result{OK: true} } ``` Maps to RFC-005's cascade model: - `M1 says NO → everything below is NO` → checker walks hierarchy, returns `{Allowed: false, Reason: "Locked by M1"}` - Training mode → checker returns `{Allowed: false, Reason: "undefined — training required"}` - Production strict mode → undefined = denied ### 21.7 Composing Both Systems When a SaaS platform ALSO has commerce hierarchy (Host UK), the checker composes internally: ```go func (s *CompositeService) check(action string, qty int, ctx context.Context) core.Entitlement { // Check commerce matrix first (hard permissions) matrixResult := s.matrix.Can(entityFromCtx(ctx), action, "") if matrixResult.IsDenied() { return core.Entitlement{Allowed: false, Reason: matrixResult.Reason} } // Then check entitlements (usage limits) entResult := s.entitlements.Can(workspaceFromCtx(ctx), action, qty) return core.Entitlement{ Allowed: entResult.IsAllowed(), Unlimited: entResult.IsUnlimited(), Limit: entResult.Limit, Used: entResult.Used, Remaining: entResult.Remaining, Reason: entResult.Message(), } } ``` Matrix (hierarchy) gates first. Entitlements (usage) gate second. One checker, composed. ### 21.8 Convenience Methods on Entitlement ```go // NearLimit returns true if usage exceeds the threshold percentage. // RFC-004: $result->isNearLimit() uses 80% threshold. // // if e.NearLimit(0.8) { showUpgradePrompt() } func (e Entitlement) NearLimit(threshold float64) bool // UsagePercent returns current usage as a percentage of the limit. // RFC-004: $result->getUsagePercentage() // // pct := e.UsagePercent() // 75.0 func (e Entitlement) UsagePercent() float64 // RecordUsage is called after a gated action succeeds. // Delegates to the entitlement service for usage tracking. // This is the equivalent of RFC-004's $workspace->recordUsage(). // // e := c.Entitled("ai.credits", 10) // if e.Allowed { // doWork() // c.RecordUsage("ai.credits", 10) // } func (c *Core) RecordUsage(action string, quantity ...int) ``` ### 21.9 Audit Trail — RFC-004 Section: Audit Logging Every entitlement check can be logged via `core.Security()`: ```go func (c *Core) Entitled(action string, quantity ...int) Entitlement { qty := 1 if len(quantity) > 0 { qty = quantity[0] } e := c.entitlementChecker(action, qty, c.Context()) // Audit logging for denials (P11-6) if !e.Allowed { Security("entitlement.denied", "action", action, "quantity", qty, "reason", e.Reason) } return e } ``` ### 21.10 Core Struct Changes ```go type Core struct { // ... existing fields ... entitlementChecker EntitlementChecker // default: everything permitted } ``` Constructor: ```go func New(opts ...CoreOption) *Core { c := &Core{ // ... existing ... entitlementChecker: defaultChecker, } // ... } ``` ### 21.11 What This Does NOT Do - **Does not add database dependencies** — Core is stdlib only. Usage tracking, package management, billing — all in consumer packages. - **Does not define features** — The feature catalogue (social.accounts, ai.credits, etc.) is defined by the SaaS platform, not Core. - **Does not manage subscriptions** — Commerce (RFC-005) and billing (Blesta/Stripe) are consumer concerns. - **Does not replace Action registration** — Registration IS capability. Entitlement IS permission. Both must be true. - **Does not enforce at Config/Data/Fs level** — v0.8.0 gates Actions. Config/Data/Fs gating requires per-subsystem entitlement checks (same pattern, more integration points). ### 21.12 The Subsystem Map (Updated) ``` c.Registry() — universal named collection c.Options() — input configuration c.App() — identity c.Config() — runtime settings c.Data() — embedded assets c.Drive() — connection config (WHERE) c.API() — remote streams (HOW) [planned] c.Fs() — filesystem c.Process() — managed execution (Action sugar) c.Action() — named callables (register, invoke, inspect) c.Task() — composed Action sequences c.IPC() — local message bus c.Cli() — command tree c.Log() — logging c.Error() — panic recovery c.I18n() — internationalisation c.Entitled() — permission check (NEW) c.RecordUsage() — usage tracking (NEW) ``` --- ## Changelog - 2026-03-25: v0.8.0 — All 21 sections implemented. 483 tests, 84.7% coverage, 100% AX-7 naming. - 2026-03-25: Initial specification created from 500k token discovery session. 108 findings, 5 root causes, 13 review passes. Discovery detail preserved in git history.