feat: implement Section 21 — Entitlement permission primitive

c.Entitled("action", quantity) checks permission before execution.
Default: everything permitted (trusted conclave).
Consumer packages replace checker via c.SetEntitlementChecker().

- Entitlement struct: Allowed, Unlimited, Limit, Used, Remaining, Reason
- NearLimit(threshold), UsagePercent() convenience methods
- EntitlementChecker function type — registered by go-entitlements/commerce-matrix
- UsageRecorder for consumption tracking after gated actions succeed
- Enforcement wired into Action.Run() — one gate for all capabilities
- Security audit logging on denials (P11-6)
- 16 AX-7 tests including full SaaS gating pattern simulation

Maps 1:1 to RFC-004 EntitlementResult and RFC-005 PermissionResult.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-25 16:17:16 +00:00
parent 14cd9c6adb
commit ec423cfe46
5 changed files with 379 additions and 3 deletions

View file

@ -44,6 +44,7 @@ type Action struct {
Description string
Schema Options // declares expected input keys (optional)
enabled bool
core *Core // for entitlement checks during Run()
}
// Run executes the action with panic recovery.
@ -57,6 +58,12 @@ func (a *Action) Run(ctx context.Context, opts Options) (result Result) {
if !a.enabled {
return Result{E("action.Run", Concat("action disabled: ", a.Name), nil), false}
}
// Entitlement check — permission boundary
if a.core != nil {
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() {
if r := recover(); r != nil {
result = Result{E("action.Run", Sprint("panic in action ", a.Name, ": ", r), nil), false}
@ -90,7 +97,7 @@ func (a *Action) safeName() string {
// c.Action("process.run").Exists() // check
func (c *Core) Action(name string, handler ...ActionHandler) *Action {
if len(handler) > 0 {
def := &Action{Name: name, Handler: handler[0], enabled: true}
def := &Action{Name: name, Handler: handler[0], enabled: true, core: c}
c.ipc.actions.Set(name, def)
return def
}

View file

@ -96,8 +96,9 @@ func New(opts ...CoreOption) *Core {
ipc: &Ipc{actions: NewRegistry[*Action](), tasks: NewRegistry[*Task]()},
info: systemInfo,
i18n: &I18n{},
services: &ServiceRegistry{Registry: NewRegistry[*Service]()},
commands: &CommandRegistry{Registry: NewRegistry[*Command]()},
services: &ServiceRegistry{Registry: NewRegistry[*Service]()},
commands: &CommandRegistry{Registry: NewRegistry[*Command]()},
entitlementChecker: defaultChecker,
}
c.context, c.cancel = context.WithCancel(context.Background())

View file

@ -32,6 +32,9 @@ type Core struct {
info *SysInfo // c.Env("key") — Read-only system/environment information
i18n *I18n // c.I18n() — Internationalisation and locale collection
entitlementChecker EntitlementChecker // default: everything permitted
usageRecorder UsageRecorder // default: nil (no-op)
context context.Context
cancel context.CancelFunc
taskIDCounter atomic.Uint64

130
entitlement.go Normal file
View file

@ -0,0 +1,130 @@
// SPDX-License-Identifier: EUPL-1.2
// Permission primitive for the Core framework.
// Entitlement answers "can [subject] do [action] with [quantity]?"
// Default: everything permitted (trusted conclave).
// With go-entitlements: checks workspace packages, features, usage, boosts.
// With commerce-matrix: checks entity hierarchy, lock cascade.
//
// Usage:
//
// e := c.Entitled("process.run") // boolean gate
// e := c.Entitled("social.accounts", 3) // quantity check
// if e.Allowed { proceed() }
// if e.NearLimit(0.8) { showUpgradePrompt() }
//
// Registration:
//
// c.SetEntitlementChecker(myChecker)
// c.SetUsageRecorder(myRecorder)
package core
import "context"
// Entitlement is the result of a permission check.
// Carries context for both boolean gates (Allowed) and usage limits (Limit/Used/Remaining).
//
// e := c.Entitled("social.accounts", 3)
// e.Allowed // true
// e.Limit // 5
// e.Used // 2
// e.Remaining // 3
// e.NearLimit(0.8) // false
type Entitlement struct {
Allowed bool // permission granted
Unlimited bool // no cap (agency tier, admin, trusted conclave)
Limit int // total allowed (0 = boolean gate)
Used int // current consumption
Remaining int // Limit - Used
Reason string // denial reason — for UI and audit logging
}
// NearLimit returns true if usage exceeds the threshold percentage.
//
// if e.NearLimit(0.8) { showUpgradePrompt() }
func (e Entitlement) NearLimit(threshold float64) bool {
if e.Unlimited || e.Limit == 0 {
return false
}
return float64(e.Used)/float64(e.Limit) >= threshold
}
// UsagePercent returns current usage as a percentage of the limit.
//
// pct := e.UsagePercent() // 75.0
func (e Entitlement) UsagePercent() float64 {
if e.Limit == 0 {
return 0
}
return float64(e.Used) / float64(e.Limit) * 100
}
// 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
// UsageRecorder records consumption after a gated action succeeds.
// Consumer packages provide the implementation (database, cache, etc).
type UsageRecorder func(action string, quantity int, ctx context.Context)
// defaultChecker — trusted conclave, everything permitted.
func defaultChecker(_ string, _ int, _ context.Context) Entitlement {
return Entitlement{Allowed: true, Unlimited: true}
}
// Entitled checks if an action is permitted in the current context.
// Default: always returns Allowed=true, Unlimited=true.
// Denials are logged via core.Security().
//
// e := c.Entitled("process.run")
// e := c.Entitled("social.accounts", 3)
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())
if !e.Allowed {
Security("entitlement.denied", "action", action, "quantity", qty, "reason", e.Reason)
}
return e
}
// 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) {
c.entitlementChecker = checker
}
// RecordUsage records consumption after a gated action succeeds.
// Delegates to the registered UsageRecorder. No-op if none registered.
//
// e := c.Entitled("ai.credits", 10)
// if e.Allowed {
// doWork()
// c.RecordUsage("ai.credits", 10)
// }
func (c *Core) RecordUsage(action string, quantity ...int) {
if c.usageRecorder == nil {
return
}
qty := 1
if len(quantity) > 0 {
qty = quantity[0]
}
c.usageRecorder(action, qty, c.Context())
}
// SetUsageRecorder registers a usage tracking function.
// Called by go-entitlements during OnStartup.
func (c *Core) SetUsageRecorder(recorder UsageRecorder) {
c.usageRecorder = recorder
}

235
entitlement_test.go Normal file
View file

@ -0,0 +1,235 @@
package core_test
import (
"context"
"testing"
. "dappco.re/go/core"
"github.com/stretchr/testify/assert"
)
// --- Entitled ---
func TestEntitlement_Entitled_Good_DefaultPermissive(t *testing.T) {
c := New()
e := c.Entitled("anything")
assert.True(t, e.Allowed, "default checker permits everything")
assert.True(t, e.Unlimited)
}
func TestEntitlement_Entitled_Good_BooleanGate(t *testing.T) {
c := New()
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
if action == "premium.feature" {
return Entitlement{Allowed: true}
}
return Entitlement{Allowed: false, Reason: "not in package"}
})
assert.True(t, c.Entitled("premium.feature").Allowed)
assert.False(t, c.Entitled("other.feature").Allowed)
assert.Equal(t, "not in package", c.Entitled("other.feature").Reason)
}
func TestEntitlement_Entitled_Good_QuantityCheck(t *testing.T) {
c := New()
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
if action == "social.accounts" {
limit := 5
used := 3
remaining := limit - used
if qty > remaining {
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
}
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
}
return Entitlement{Allowed: true, Unlimited: true}
})
// Can create 2 more (3 used of 5)
e := c.Entitled("social.accounts", 2)
assert.True(t, e.Allowed)
assert.Equal(t, 5, e.Limit)
assert.Equal(t, 3, e.Used)
assert.Equal(t, 2, e.Remaining)
// Can't create 3 more
e = c.Entitled("social.accounts", 3)
assert.False(t, e.Allowed)
assert.Equal(t, "limit exceeded", e.Reason)
}
func TestEntitlement_Entitled_Bad_Denied(t *testing.T) {
c := New()
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
return Entitlement{Allowed: false, Reason: "locked by M1"}
})
e := c.Entitled("product.create")
assert.False(t, e.Allowed)
assert.Equal(t, "locked by M1", e.Reason)
}
func TestEntitlement_Entitled_Ugly_DefaultQuantityIsOne(t *testing.T) {
c := New()
var receivedQty int
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
receivedQty = qty
return Entitlement{Allowed: true}
})
c.Entitled("test")
assert.Equal(t, 1, receivedQty, "default quantity should be 1")
}
// --- Action.Run Entitlement Enforcement ---
func TestEntitlement_ActionRun_Good_Permitted(t *testing.T) {
c := New()
c.Action("work", func(_ context.Context, _ Options) Result {
return Result{Value: "done", OK: true}
})
r := c.Action("work").Run(context.Background(), NewOptions())
assert.True(t, r.OK)
assert.Equal(t, "done", r.Value)
}
func TestEntitlement_ActionRun_Bad_Denied(t *testing.T) {
c := New()
c.Action("restricted", func(_ context.Context, _ Options) Result {
return Result{Value: "should not reach", OK: true}
})
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
if action == "restricted" {
return Entitlement{Allowed: false, Reason: "tier too low"}
}
return Entitlement{Allowed: true, Unlimited: true}
})
r := c.Action("restricted").Run(context.Background(), NewOptions())
assert.False(t, r.OK, "denied action must not execute")
err, ok := r.Value.(error)
assert.True(t, ok)
assert.Contains(t, err.Error(), "not entitled")
assert.Contains(t, err.Error(), "tier too low")
}
func TestEntitlement_ActionRun_Good_OtherActionsStillWork(t *testing.T) {
c := New()
c.Action("allowed", func(_ context.Context, _ Options) Result {
return Result{Value: "ok", OK: true}
})
c.Action("blocked", func(_ context.Context, _ Options) Result {
return Result{Value: "nope", OK: true}
})
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
if action == "blocked" {
return Entitlement{Allowed: false, Reason: "nope"}
}
return Entitlement{Allowed: true, Unlimited: true}
})
assert.True(t, c.Action("allowed").Run(context.Background(), NewOptions()).OK)
assert.False(t, c.Action("blocked").Run(context.Background(), NewOptions()).OK)
}
// --- NearLimit ---
func TestEntitlement_NearLimit_Good(t *testing.T) {
e := Entitlement{Allowed: true, Limit: 100, Used: 85, Remaining: 15}
assert.True(t, e.NearLimit(0.8))
assert.False(t, e.NearLimit(0.9))
}
func TestEntitlement_NearLimit_Bad_Unlimited(t *testing.T) {
e := Entitlement{Allowed: true, Unlimited: true}
assert.False(t, e.NearLimit(0.8), "unlimited should never be near limit")
}
func TestEntitlement_NearLimit_Ugly_ZeroLimit(t *testing.T) {
e := Entitlement{Allowed: true, Limit: 0}
assert.False(t, e.NearLimit(0.8), "boolean gate (limit=0) should not report near limit")
}
// --- UsagePercent ---
func TestEntitlement_UsagePercent_Good(t *testing.T) {
e := Entitlement{Limit: 100, Used: 75}
assert.Equal(t, 75.0, e.UsagePercent())
}
func TestEntitlement_UsagePercent_Ugly_ZeroLimit(t *testing.T) {
e := Entitlement{Limit: 0, Used: 5}
assert.Equal(t, 0.0, e.UsagePercent(), "zero limit = boolean gate, no percentage")
}
// --- RecordUsage ---
func TestEntitlement_RecordUsage_Good(t *testing.T) {
c := New()
var recorded string
var recordedQty int
c.SetUsageRecorder(func(action string, qty int, ctx context.Context) {
recorded = action
recordedQty = qty
})
c.RecordUsage("ai.credits", 10)
assert.Equal(t, "ai.credits", recorded)
assert.Equal(t, 10, recordedQty)
}
func TestEntitlement_RecordUsage_Good_NoRecorder(t *testing.T) {
c := New()
// No recorder set — should not panic
assert.NotPanics(t, func() {
c.RecordUsage("anything", 5)
})
}
// --- Permission Model Integration ---
func TestEntitlement_Ugly_SaaSGatingPattern(t *testing.T) {
c := New()
// Simulate RFC-004 entitlement service
packages := map[string]int{
"social.accounts": 5,
"social.posts.scheduled": 100,
"ai.credits": 50,
}
usage := map[string]int{
"social.accounts": 3,
"social.posts.scheduled": 45,
"ai.credits": 48,
}
c.SetEntitlementChecker(func(action string, qty int, ctx context.Context) Entitlement {
limit, hasFeature := packages[action]
if !hasFeature {
return Entitlement{Allowed: false, Reason: "feature not in package"}
}
used := usage[action]
remaining := limit - used
if qty > remaining {
return Entitlement{Allowed: false, Limit: limit, Used: used, Remaining: remaining, Reason: "limit exceeded"}
}
return Entitlement{Allowed: true, Limit: limit, Used: used, Remaining: remaining}
})
// Can create 2 social accounts
e := c.Entitled("social.accounts", 2)
assert.True(t, e.Allowed)
// AI credits near limit
e = c.Entitled("ai.credits", 1)
assert.True(t, e.Allowed)
assert.True(t, e.NearLimit(0.8))
assert.Equal(t, 96.0, e.UsagePercent())
// Feature not in package
e = c.Entitled("premium.feature")
assert.False(t, e.Allowed)
}