feat(contract): add the change proposal record

A model can ask to change the person's own state (a memory note, a
profile, a skill). Add the versioned wire record, its kinds, scopes,
statuses, source and limits, so every surface that runs a model, and
any add-on, shares one type without platform code.

Assisted-by: Claude Code:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 09:23:59 +00:00
1 parent 0b07d7fa83
commit f7c8dd15d6
2 files changed
+181

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: MIT
package contract
import (
"slices"
"time"
)
// A change proposal is how a model asks to change the person's own state: a
// memory note, a profile, a skill. The host stores it, shows it to the owner
// and writes it only after the owner confirms. Every surface that can run a
// model uses the same record: a Studio chat, a connector chat of any platform
// and an add-on page. An add-on never needs platform code for it. The model
// calls the host tool propose_change, and a connector carries the text of the
// tool result like any other answer.
//
// The record is versioned. A reader must ignore fields it does not know.
// ChangeProposalVersion is the version of the ChangeProposal wire record.
const ChangeProposalVersion = 1
// ChangeKind is what a proposal changes.
type ChangeKind string
// The kinds of change. The host decides which ones it offers.
const (
ChangeMemory ChangeKind = "memory"
ChangeProfile ChangeKind = "profile"
ChangeSkill ChangeKind = "skill"
ChangePage ChangeKind = "page"
ChangeDashboard ChangeKind = "dashboard"
)
// ChangeKinds lists every kind in a stable order.
func ChangeKinds() []ChangeKind {
return []ChangeKind{ChangeMemory, ChangeProfile, ChangeSkill, ChangePage, ChangeDashboard}
}
// ValidChangeKind reports whether s names a kind.
func ValidChangeKind(s string) bool { return slices.Contains(ChangeKinds(), ChangeKind(s)) }
// ChangeScope says whose state a proposal changes.
type ChangeScope string
// The scopes. user: the person, in every chat. agent: one saved agent.
// channel: one connector conversation.
const (
ScopeUser ChangeScope = "user"
ScopeAgent ChangeScope = "agent"
ScopeChannel ChangeScope = "channel"
)
// ValidChangeScope reports whether s names a scope.
func ValidChangeScope(s string) bool {
switch ChangeScope(s) {
case ScopeUser, ScopeAgent, ScopeChannel:
return true
}
return false
}
// ProposalStatus is where a proposal is in its life.
type ProposalStatus string
// The statuses. Only pending can change. Applied can become reverted.
const (
ProposalPending ProposalStatus = "pending"
ProposalApplied ProposalStatus = "applied"
ProposalDismissed ProposalStatus = "dismissed"
ProposalExpired ProposalStatus = "expired"
ProposalReverted ProposalStatus = "reverted"
// ProposalStale: the target changed after the proposal, so it was refused.
ProposalStale ProposalStatus = "stale"
)
// Settled reports whether the proposal has an answer.
func (s ProposalStatus) Settled() bool { return s != ProposalPending }
// ChangeSource says where a proposal came from, without naming a platform.
// Kind is the origin of the chat as the host names it ("" for a chat a person
// watches, "connector" for a messaging app). ID names the binding of a
// connector chat. Chat is the host chat. Thread is for a connector that has
// threads; the host fills it when it knows it.
type ChangeSource struct {
Kind string `json:"kind,omitempty"`
ID string `json:"id,omitempty"`
Chat string `json:"chat,omitempty"`
Thread string `json:"thread,omitempty"`
}
// ChangeProposal is one proposed change. Content is the full new text of the
// target. BaseHash is the SHA-256 (hex) of the target when the model proposed,
// or empty when the target did not exist; a proposal is refused if the target
// has another hash when the owner confirms.
type ChangeProposal struct {
V int `json:"v"`
ID string `json:"id"`
Owner string `json:"owner,omitempty"`
Agent string `json:"agent,omitempty"`
Kind ChangeKind `json:"kind"`
Scope ChangeScope `json:"scope"`
Target string `json:"target"`
Summary string `json:"summary,omitempty"`
Content string `json:"content,omitempty"`
BaseHash string `json:"base_hash,omitempty"`
Source ChangeSource `json:"source,omitzero"`
Status ProposalStatus `json:"status"`
// Rev is the workspace change set that applying wrote; RevertRev the one
// that reverting wrote. Zero until then.
Rev int64 `json:"rev,omitempty"`
RevertRev int64 `json:"revert_rev,omitempty"`
Created time.Time `json:"created"`
Expires time.Time `json:"expires"`
Answered time.Time `json:"answered,omitzero"`
}
// The limits of the host. They are decisions of the owner of the product, not
// settings of an add-on.
const (
// ChangeExpiry is how long a proposal waits for an answer.
ChangeExpiry = 24 * time.Hour
// MaxPendingChanges is the most proposals of one person waiting at once.
MaxPendingChanges = 10
// MaxChangesPerHour is the most proposals one agent (or one plain chat
// user) makes in an hour.
MaxChangesPerHour = 5
// MaxChangeBytes is the largest content of a proposal.
MaxChangeBytes = 16 << 10
// MaxProfileBytes is the largest agent profile.
MaxProfileBytes = 4 << 10
)
@@ -0,0 +1,50 @@
// SPDX-License-Identifier: MIT
package contract
import (
"encoding/json"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("change proposals", func() {
It("names every kind and status once", func() {
Expect(ChangeKinds()).To(ConsistOf(ChangeMemory, ChangeProfile, ChangeSkill, ChangePage, ChangeDashboard))
for _, k := range ChangeKinds() {
Expect(ValidChangeKind(string(k))).To(BeTrue(), "kind %s", k)
}
Expect(ValidChangeKind("Memory")).To(BeFalse())
Expect(ValidChangeKind("")).To(BeFalse())
Expect(ValidChangeScope("user")).To(BeTrue())
Expect(ValidChangeScope("agent")).To(BeTrue())
Expect(ValidChangeScope("channel")).To(BeTrue())
Expect(ValidChangeScope("team")).To(BeFalse())
})
It("tells a settled status from a pending one", func() {
Expect(ProposalPending.Settled()).To(BeFalse())
for _, s := range []ProposalStatus{ProposalApplied, ProposalDismissed, ProposalExpired, ProposalReverted, ProposalStale} {
Expect(s.Settled()).To(BeTrue(), "status %s", s)
}
})
It("keeps the wire version and omits empty fields", func() {
p := ChangeProposal{V: ChangeProposalVersion, ID: "c1", Kind: ChangeMemory, Scope: ScopeUser, Target: "index.md",
Status: ProposalPending, Created: time.Date(2026, 11, 3, 9, 0, 0, 0, time.UTC)}
raw, err := json.Marshal(p)
Expect(err).NotTo(HaveOccurred())
Expect(string(raw)).To(ContainSubstring(`"v":1`))
Expect(string(raw)).NotTo(ContainSubstring(`"source"`))
Expect(string(raw)).NotTo(ContainSubstring(`"rev"`))
})
It("has the limits the owner decided", func() {
Expect(ChangeExpiry).To(Equal(24 * time.Hour))
Expect(MaxPendingChanges).To(Equal(10))
Expect(MaxChangesPerHour).To(Equal(5))
Expect(MaxProfileBytes).To(Equal(4 << 10))
Expect(MaxChangeBytes).To(Equal(16 << 10))
})
})