From f7c8dd15d6cdbd7b0de29df6473a8db732af09a3 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 09:23:43 +0000 Subject: [PATCH] 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 --- pkg/addons/contract/changeproposal.go | 131 +++++++++++++++++++++ pkg/addons/contract/changeproposal_test.go | 50 ++++++++ 2 files changed, 181 insertions(+) create mode 100644 pkg/addons/contract/changeproposal.go create mode 100644 pkg/addons/contract/changeproposal_test.go diff --git a/pkg/addons/contract/changeproposal.go b/pkg/addons/contract/changeproposal.go new file mode 100644 index 0000000000..94816dcd47 --- /dev/null +++ b/pkg/addons/contract/changeproposal.go @@ -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 +) diff --git a/pkg/addons/contract/changeproposal_test.go b/pkg/addons/contract/changeproposal_test.go new file mode 100644 index 0000000000..eb50011dc7 --- /dev/null +++ b/pkg/addons/contract/changeproposal_test.go @@ -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)) + }) +})