Scope
Package matrix/cortex/scope is the cryptographic privacy boundary for cortex reads and writes issued by sub-agents. Merkle-proof scoping with signed CortexScope.
Package matrix/cortex/scope is the cryptographic privacy boundary for cortex reads and writes issued by sub-agents. A CortexScope binds a sub-agent's identity to a pinned snapshot root, a set of allowed memories, a Merkle multi-proof, and an optional writable bit. The cortex verifies the scope on every scoped call (signature, expiry, snapshot resolvability, and proof validity) before applying per-candidate Allows checks.
Source files: cortex/scope/scope.go, cortex/scope/verify.go, cortex/scope/match.go, cortex/scope/errors.go, cortex/scope_enforce.go.
Design decisions
Cortex never signs. Scope creation and signing lives in the agent runtime or sub-dispatch executor. Cortex only verifies. Key material never touches the cortex layer (D4). Sign / Encode helpers in the scope package are callable by the runtime and tests but are NOT a cortex API surface.
Merkle proofs, not API trust. The sub-agent receives a multi-proof against the pinned snapshot root alongside the scope. It can verify its allowed keys independently, no need to trust an API call. This is the "why Merkle proofs not API trust" principle from research/06-agents.md §7.
Verify once, filter per-candidate. VerifyScope runs the full chain (schema, empty-include reject, expiry, signature, snapshot, proofs) once at the start of Find, Context, or ResolveScoped. Per-candidate filtering then calls Scope.Allows(&head) cheaply without re-running the crypto chain.
Default deny for writes. A scope without Writable=true returns ErrNotWritable on any UpdateHead attempt, regardless of Allows. Sub-agents are read-only by default.
Empty Include matches nothing. An empty Include selector is default-deny (the more secure default per research/06-agents.md §12). Caller MUST populate at least one criterion to grant access. Verify rejects empty-include scopes at the boundary.
Wire format = canonical CBOR with SchemaVersion byte mixed in. Mirrors the snapshot.Manifest posture. The HPS envelope is a tools/attest concern at the chain boundary.
The Scope struct
type Scope struct {
SchemaVersion uint8 // mixed into signed bytes; schema bump invalidates outstanding scopes
Actor string // whose cortex
SnapshotHash [32]byte // OverallRoot at scope creation time
Include Selector // what the sub-agent MAY read
Exclude Selector // belt-and-suspenders deny list (applied after Include)
Proofs *snapshot.MultiProof // nil for Type/Tag/Frame-only scopes
ExpiresAt time.Time // zero = never expires (rare; production scopes always expire)
BudgetTokens int // hard cap on cortex.Context budget; 0 = uncapped
GrantedBy string // parent agent ref (who signed this scope)
GrantedTo string // sub-agent ref (carried for audit)
Writable bool // default false; required for UpdateHead/Write/Tombstone/AddEdge
Signature []byte // ed25519 sig by GrantedBy over UnsignedBytes(s)
}
Selector
type Selector struct {
Types []memory.Type // all memories of these types
Tags []memory.Tag // all memories having any of these tags
IDs []memory.ID // specific memory IDs; requires Proofs
Frame *FrameFilter // memories matching (verb, obj_hash) tuples
}
Include matches if the memory satisfies ANY of the populated criteria (set union). Exclude is applied after Include matches to further restrict access.
FrameFilter
type FrameFilter struct {
Verb memory.Verb
ObjHashes [][memory.ObjHashSize]byte
}
Matches when the memory's Head.Frames contains a FrameRef with matching Verb and Hash() in ObjHashes.
Creating and signing a scope
Scope creation lives in the agent runtime. The cortex package provides the helpers:
// Build unsigned bytes (canonical CBOR with Signature=nil)
unsigned := scope.UnsignedBytes(s)
// Sign with ed25519
s.Signature = ed25519.Sign(privKey, unsigned)
// Or use the helper
err := scope.Sign(s, privKey)
Encoding:
bytes, err := scope.EncodeScope(s) // canonical CBOR including Signature
s, err := scope.DecodeScope(bytes) // parse
Verification chain
scope.Verify(s, snapState, resolver, opts) checks:
SchemaVersionmatches the package constant.Includeis non-empty.now <= ExpiresAt(orExpiresAtis zero).GrantedBy's public key is resolved viaKeyResolver.Signatureis a valid ed25519 signature overUnsignedBytes(s).SnapshotHashis resolvable, there exists asnap/<seq>manifest with thatOverallRoot.- If
Proofsis non-nil:len(Proofs.Proofs) == len(Include.IDs), each proof'sKeyHashmatches the ID-derived hash, and the multi-proof verifies against the resolved manifest's memories SMT root.
err := c.VerifyScope(s, time.Now()) // cortex facade
KeyResolver
type KeyResolver interface {
ResolveAgentKey(ref string) (ed25519.PublicKey, error)
}
Injected at cortex construction via cortex.WithKeyResolver(r). Cortex never holds key material; the resolver lives in the agent runtime / tools/registry layer. A cortex constructed without a resolver rejects all scoped calls with ErrNoKeyResolver.
Scope enforcement choke point
Three functions in cortex/scope_enforce.go are the sole choke points:
// Once per call, full crypto chain
func (c *Cortex) VerifyScope(s *scope.Scope, now time.Time) error
// Per-candidate in Find / Context / ResolveScoped, cheap Allows check
func (c *Cortex) enforceRead(s *scope.Scope, h *memory.Head) error
// Per-target in UpdateHead, requires Writable + Allows
func (c *Cortex) enforceWrite(s *scope.Scope, h *memory.Head) error
enforceRead on a miss journals a KindScopeViolation entry and returns scope.ErrViolation. Context and Find (multi-target reads) filter silently without journaling per-candidate violations. ResolveScoped (single-target) does journal the violation.
Scope violations
A violation is logged as a KindScopeViolation journal entry carrying:
GrantedTo- the sub-agent that violatedGrantedBy- the parent who issued the scopeMemoryID- the memory that was accessed or attemptedReason- "violation" or "not_writable"Mode- "read" or "write"
Rate limiting
Scope violation logging is protected by a per-(GrantedTo, GrantedBy) token bucket (10/sec, burst 20). Over-rate violations still return scope.ErrViolation to the caller, but the journal write and its MMR cascade are suppressed. This bounds the OverallRoot-moving + Pebble-sync cost a malicious sub-agent can impose by looping violations.
Using a scope
Scoped reads pass the scope on the query or resolve call:
// Single-target read
mem, err := c.ResolveScoped(uri, scope, time.Now())
// Multi-target Find
result, err := c.Find(query.Query{
Type: []memory.Type{memory.TypeFact},
Scope: scope,
Limit: 10,
})
// Context bundle (also caps BudgetTokens against Scope.BudgetTokens)
bundle, err := c.Context(cortex.ContextOpts{
Verb: memory.VerbFind,
Scope: scope,
})
// Head-only write (requires Writable=true)
_, err = c.UpdateHead(uri, patch, cortex.UpdateHeadMeta{Scope: scope})
Error reference
| Error | Cause |
|---|---|
ErrViolation | Memory outside Include or inside Exclude |
ErrNotWritable | UpdateHead attempted with Scope.Writable=false |
ErrScopeExpired | now > ExpiresAt |
ErrSchemaVersion | Scope SchemaVersion does not match package constant |
ErrSnapshotUnresolved | SnapshotHash not found in any snap/<seq> manifest |
ErrProofMismatch | Proof count/key-hash mismatch vs Include.IDs |
ErrEmptyInclude | Include has no populated criteria - nothing is allowed |
ErrActorMismatch | Scope.Actor does not match store actor |
ErrUnknownAgent | KeyResolver.ResolveAgentKey returned unknown ref |
ErrNoKeyResolver | Scoped call on a cortex without WithKeyResolver |
ErrBudgetExceeded | Context request exceeds Scope.BudgetTokens |
ErrSignatureInvalid | Ed25519 signature verification failed |
Modifying scope
| What to change | Where |
|---|---|
| Selector membership criteria | scope/match.go - Allows |
| Verification chain steps | scope/verify.go - Verify |
| Scope wire format | scope/scope.go - Scope struct; bump SchemaVersion |
| Scope violation rate limits | cortex/ratelimit.go - DefaultRateLimits().ScopeViolation |
| Key resolver implementation | Agent runtime, implement scope.KeyResolver |
