Documentation
¶
Overview ¶
Package proposal manages pending agent-originated entities awaiting user confirmation before they become real (goals, projects, tasks, concepts).
Index ¶
- Constants
- Variables
- func AcceptOrchestration(ctx context.Context, id uuid.UUID, adapter AcceptAdapter) (*db.PendingProposal, any, error)
- func DecodeDecisionParams(payload []byte) (decision.LogParams, error)
- func DecodeGoalParams(payload []byte) (gtd.CreateGoalParams, error)
- func DecodeProjectParams(payload []byte) (gtd.CreateProjectParams, error)
- func DecodeTaskParams(payload []byte, strict bool) (gtd.CreateTaskParams, []string, error)
- func ShouldAutoProposeFor(item *db.KnowledgeItem) bool
- type AcceptAdapter
- type BaseAcceptAdapter
- type BatchConfirmResult
- type BatchItemResult
- type ConceptCandidate
- type ConceptPayload
- type CreateParams
- type DecisionProposerPayload
- type KnowledgePayload
- type PgAcceptDeps
- type Status
- type Store
- func (s *Store) AutoProposeConceptFromKnowledge(ctx context.Context, item *db.KnowledgeItem, proposedBy string) (*db.PendingProposal, error)
- func (s *Store) BatchConfirm(ctx context.Context, ids []uuid.UUID, status Status) (BatchConfirmResult, error)
- func (s *Store) Create(ctx context.Context, p CreateParams) (*db.PendingProposal, error)
- func (s *Store) Get(ctx context.Context, id uuid.UUID) (*db.PendingProposal, error)
- func (s *Store) ListAll(ctx context.Context, proposalType string, limit int32) ([]db.PendingProposal, error)
- func (s *Store) ListPending(ctx context.Context) ([]db.PendingProposal, error)
- func (s *Store) Resolve(ctx context.Context, id uuid.UUID, status Status) (*db.PendingProposal, error)
- func (s *Store) WithTx(tx pgx.Tx) *Store
- type StoreIface
- type TaskPayload
- type Type
Constants ¶
const MaxTaskPayloadBytes = 4 * 1024
MaxTaskPayloadBytes caps the JSON-encoded TaskPayload size before persist. 4 KiB is comfortably above the redacted arg/result summaries (each ≤ 500/300 runes) and any classifier rationale, but small enough to bound DB row size in a high-frequency auto-capture loop.
Variables ¶
var ErrAlreadyResolved = errors.New("proposal: already resolved")
ErrAlreadyResolved is returned by AcceptOrchestration when the proposal fetched by ReadPending is not in StatusPending. Unlike gtd.BeginTaskOrchestration's idempotency shortcut ("already in the target state" is a silent success), accepting a non-pending proposal is always an error here: there is no "re-accepting" a proposal — accepted stays accepted, rejected stays rejected — so silently returning the stale row would hide a caller bug or a race with a concurrent resolver instead of surfacing it.
var ErrNotFound = errors.New("proposal: not found")
ErrNotFound is returned when no matching pending proposal exists. A proposal already resolved (accepted/rejected) also returns ErrNotFound on Resolve to keep the operation idempotent.
Functions ¶
func AcceptOrchestration ¶
func AcceptOrchestration(ctx context.Context, id uuid.UUID, adapter AcceptAdapter) (*db.PendingProposal, any, error)
AcceptOrchestration runs the dialect-agnostic proposal-acceptance control flow against adapter: ReadPending → PrepareOutOfBand → BeginTx → Materialize → ResolveAccepted → Commit. id is used only for error-message context — the adapter itself already knows which proposal it was constructed for (mirrors gtd.BeginTaskOrchestration's id parameter).
Unlike BeginTaskOrchestration, there is no idempotency short-circuit: a proposal not in StatusPending always returns ErrAlreadyResolved, never a silent success — see that sentinel's doc comment for why.
func DecodeDecisionParams ¶
DecodeDecisionParams decodes a type=decision pending_proposals.payload (DecisionProposerPayload, defined in payload.go, same package) into decision.LogParams. Ported from internal/mcp/tools_proposal.go's decodeDecisionParams, alternatives-into-rationale concatenation included.
func DecodeGoalParams ¶
func DecodeGoalParams(payload []byte) (gtd.CreateGoalParams, error)
DecodeGoalParams decodes a type=goal pending_proposals.payload into gtd.CreateGoalParams. Ported from internal/mcp/tools_proposal.go's decodeGoalParams (identical validation; `error` instead of a `string` error-message return since this is new code, not a byte-identical port).
func DecodeProjectParams ¶
func DecodeProjectParams(payload []byte) (gtd.CreateProjectParams, error)
DecodeProjectParams decodes a type=project pending_proposals.payload into gtd.CreateProjectParams. Ported from internal/mcp/tools_proposal.go's decodeProjectParams.
func DecodeTaskParams ¶
DecodeTaskParams decodes a type=task pending_proposals.payload (TaskPayload, defined in payload.go) into gtd.CreateTaskParams, running the same validator.CheckTaskInput vagueness check internal/mcp/tools_proposal.go's decodeTaskProposalParams runs. strict mirrors WBT_STRICT_VAGUENESS (validator.StrictModeEnabled()): true turns warnings into a fatal error, false returns them alongside ok params.
func ShouldAutoProposeFor ¶
func ShouldAutoProposeFor(item *db.KnowledgeItem) bool
ShouldAutoProposeFor returns true when a knowledge item type is suitable for becoming a spaced-repetition concept. Pure bookmarks (just a saved URL with little content) are excluded — proposing them as review cards is noise.
Types ¶
type AcceptAdapter ¶
type AcceptAdapter interface {
// ReadPending fetches the proposal row the adapter was constructed for,
// scoped to the backend's configured workspace, translating the
// backend's own not-found signal into this package's ErrNotFound.
ReadPending(ctx context.Context) (*db.PendingProposal, error)
// PrepareOutOfBand performs any work that must NOT run inside a database
// transaction — currently only TypeKnowledge's embedding generation
// (external network call) plus its cosine-similarity dedup check (see
// ADR 0003's G1 "先算後寫": compute before write, so a transaction never
// wraps an external network call). Called BEFORE BeginTx. The other 6
// proposal types are no-ops — see BaseAcceptAdapter, embedded by both
// production adapters so they don't each write an empty override. On
// success, prepared is an opaque adapter-private value handed back to
// Materialize unchanged (currently a knowledge.PreparedItem for
// TypeKnowledge, nil for everything else).
PrepareOutOfBand(ctx context.Context, prop *db.PendingProposal) (prepared any, err error)
// BeginTx opens the transaction used for materialise + resolve.
BeginTx(ctx context.Context) error
// Materialize creates the concrete entity (goal/project/concept/decision/
// task/knowledge/playbook) inside the open tx, dispatching on prop.Type.
// prepared is whatever PrepareOutOfBand returned. created is the entity
// to surface to the caller (nil for types with no representable "created"
// value); a non-nil error aborts the whole Accept call — the deferred
// Rollback fires and the proposal stays pending.
Materialize(ctx context.Context, prop *db.PendingProposal, prepared any) (created any, err error)
// ResolveAccepted flips the proposal (the one the adapter was
// constructed for) to accepted inside the open tx.
ResolveAccepted(ctx context.Context) (*db.PendingProposal, error)
// Commit commits the open tx.
Commit(ctx context.Context) error
// Rollback rolls back the open tx. Safe to call after the tx has already
// been closed by Commit (both pgx.Tx and database/sql.Tx guarantee a
// redundant Rollback is a no-op), so AcceptOrchestration defers it
// unconditionally rather than tracking its own committed flag.
Rollback(ctx context.Context)
}
AcceptAdapter is the narrow, per-backend seam AcceptOrchestration drives to execute proposal acceptance's control flow — read pending → prepare out-of-band work → begin tx → materialise → resolve → commit — without seeing pgx or database/sql types. See docs/adr/0003-dual-backend-orchestration-seam-principle.md for the bounded exception this seam is built under; docs/adr/0002 (internal/gtd's BeginTaskAdapter) is the first instance, this is the second.
An adapter is a short-lived, single-use value constructed fresh per Accept call: it holds the open transaction (and, once opened, the proposal row) as state between method calls, so implementations are not safe for concurrent reuse across calls. The two production adapters are internal/proposal/accept_pg.go's pgAcceptAdapter and internal/storage/sqlite/accept_proposal.go's sqliteAcceptAdapter.
func NewPgAcceptAdapter ¶
func NewPgAcceptAdapter(id uuid.UUID, deps PgAcceptDeps) AcceptAdapter
NewPgAcceptAdapter constructs a fresh pgAcceptAdapter for one AcceptOrchestration(ctx, id, adapter) call.
type BaseAcceptAdapter ¶
type BaseAcceptAdapter struct{}
BaseAcceptAdapter provides the no-op PrepareOutOfBand default shared by 6 of the 7 proposal types, which have no out-of-band work to run before BeginTx. Embed this value in a concrete AcceptAdapter and override PrepareOutOfBand only for the type(s) that need it (currently only TypeKnowledge, on both production adapters) instead of every adapter implementation writing its own empty method.
func (BaseAcceptAdapter) PrepareOutOfBand ¶
func (BaseAcceptAdapter) PrepareOutOfBand(context.Context, *db.PendingProposal) (any, error)
PrepareOutOfBand is a no-op returning (nil, nil).
type BatchConfirmResult ¶
type BatchConfirmResult struct {
Results []BatchItemResult `json:"results"`
// Accepted is the count of proposals successfully resolved to the requested action.
Accepted int `json:"accepted"`
// Failed is the count of proposals that could not be resolved (not found,
// already resolved, or other error). On the Postgres path a single failure
// triggers a full rollback and all entries become failed.
Failed int `json:"failed"`
}
BatchConfirmResult is the aggregate result returned by BatchConfirm.
type BatchItemResult ¶
type BatchItemResult struct {
ID string `json:"id"`
OK bool `json:"ok"`
ErrMsg string `json:"error,omitempty"`
}
BatchItemResult reports the outcome of a single ID inside a BatchConfirm call.
type ConceptCandidate ¶
type ConceptCandidate struct {
Title string `json:"title"`
Content string `json:"content"`
Tags []string `json:"tags,omitempty"`
SourceItemID string `json:"source_item_id,omitempty"` // knowledge_items.id that triggered the proposal
SourceItemType string `json:"source_item_type,omitempty"` // "article" / "til" / etc.
}
ConceptCandidate is the on-disk shape of a concept proposal payload. Stored as JSONB inside pending_proposals.payload when type='concept'.
type ConceptPayload ¶
type ConceptPayload struct {
Title string `json:"title"`
Content string `json:"content"`
Tags []string `json:"tags,omitempty"`
}
ConceptPayload is the JSONB shape stored in pending_proposals when type=concept. Exported (unlike goalPayload/projectPayload above) because DecodeConceptPayload returns it directly to the caller, which lives in a different package for the SQLite adapter. Mirrors internal/mcp/tools_proposal.go's private conceptPayload (SourceItemID / SourceItemType omitted — unused by either adapter's materialise step).
func DecodeConceptPayload ¶
func DecodeConceptPayload(payload []byte) (ConceptPayload, error)
DecodeConceptPayload decodes and validates a type=concept pending_proposals.payload. Ported from internal/mcp/tools_proposal.go's decodeConceptPayload (same length caps).
type CreateParams ¶
type CreateParams struct {
WorkspaceID *uuid.UUID // nil → unscoped (Phase B1: always nil; B2 wires real workspace)
Type Type
Payload []byte // JSON-encoded proposal body (entity-specific shape)
ProposedBy string // empty → NULL; e.g. "claude-code", "discord-bot"
}
CreateParams captures the fields required to record a new proposal.
type DecisionProposerPayload ¶
type DecisionProposerPayload struct {
Title string `json:"title"`
Decision string `json:"decision"`
Rationale string `json:"rationale"`
Alternatives []string `json:"alternatives,omitempty"`
SessionID string `json:"session_id"`
TriggerTool string `json:"trigger_tool"`
}
DecisionProposerPayload is the JSONB shape persisted into pending_proposals when type='decision'. It is created by the auto-decision-proposer middleware (internal/mcp/middleware_decision_proposer.go) AFTER a successful mutating MCP tool call when no log_decision/confirm_plan happened in the recent window. confirm_proposal materialises an accepted row into the `decisions` table by calling decision.Store.Log with the matching fields.
Lives in the proposal package (rather than internal/mcp) so both the middleware (writer) and the materialiser in tools_proposal.go (reader) can import the same shape without a circular dependency (proposal MUST NOT import mcp, but mcp already imports proposal).
type KnowledgePayload ¶
type KnowledgePayload struct {
Title string `json:"title"`
Content string `json:"content"`
Tags []string `json:"tags,omitempty"`
// SourceEntityID is an opaque foreign entity UUID (as string; NEVER a real
// FK per CLAUDE.md red-line #9) that scheduler jobs stamp so a follow-up
// run can SQL-dedup against it via payload->>'source_entity_id' instead of
// re-scanning application-side. Mirrors TaskPayload.SourceEntityID's same
// rationale — see its doc comment. Written by
// scheduler.runKnowledgeToSkillCandidate (keyed on knowledge_items.id) to
// prevent the job from re-proposing the same high-recall item every run.
// Empty for non-scheduler producers (weekly_goal_review,
// behavior_rule_candidate, reflection/consolidation crons — none of those
// dedup on a source entity today).
SourceEntityID string `json:"source_entity_id,omitempty"`
}
KnowledgePayload is the JSONB shape stored in pending_proposals.payload when type='knowledge'. Written by the reflection cron job (internal/scheduler/reflection.go) and read by the confirm_proposal materialiser. Centralised here so scheduler and materialiser share the same wire format without a cross-package import.
func DecodeKnowledgePayload ¶
func DecodeKnowledgePayload(payload []byte) (KnowledgePayload, error)
DecodeKnowledgePayload decodes and validates a type=knowledge pending_proposals.payload (KnowledgePayload, defined in payload.go). Ported from internal/mcp/tools_proposal.go's decodeKnowledgePayload (same length caps).
type PgAcceptDeps ¶
type PgAcceptDeps struct {
Pool *pgxpool.Pool
Proposal *Store
GTD *gtd.Store
Learning *learning.Store
Decision *decision.Store
Knowledge *knowledge.Store
}
PgAcceptDeps groups the concrete Postgres store handles pgAcceptAdapter needs. Threaded in from a storage.ServerStores by the caller (internal/storage/accept_seam.go's AcceptSeam) rather than passed as a storage.ServerStores value directly: internal/storage already imports internal/proposal (server_stores.go's Proposal() accessor / factory.go), so internal/proposal importing internal/storage back would cycle. Hence AcceptSeam itself lives in internal/storage, not here — see that file's doc comment for the full explanation; this is a deliberate deviation from the dispatch's literal `internal/proposal/accept_seam.go` file location.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store handles all database operations for the Proposal bounded context.
func NewStore ¶
NewStore returns a Store backed by the given DBTX scoped to the optional workspace. nil workspaceID = legacy unscoped mode.
func (*Store) AutoProposeConceptFromKnowledge ¶
func (s *Store) AutoProposeConceptFromKnowledge( ctx context.Context, item *db.KnowledgeItem, proposedBy string, ) (*db.PendingProposal, error)
AutoProposeConceptFromKnowledge creates a pending concept proposal from a freshly added knowledge item. The caller decides whether to expose the returned proposal ID to its consumer.
Errors are returned to the caller so they can decide whether to fail the outer request (e.g. MCP) or fail-soft (e.g. HTTP, where the knowledge item is already created and shouldn't be lost just because the proposal failed).
func (*Store) BatchConfirm ¶
func (s *Store) BatchConfirm(ctx context.Context, ids []uuid.UUID, status Status) (BatchConfirmResult, error)
BatchConfirm resolves multiple proposals inside a single Postgres transaction. If any individual Resolve fails (not found, already resolved, DB error) the whole transaction is rolled back and all entries are reported as failed. Callers must have already validated ids (len 1–100) and status.
func (*Store) Create ¶
func (s *Store) Create(ctx context.Context, p CreateParams) (*db.PendingProposal, error)
Create records a new pending proposal. Payload is opaque JSON; the caller is responsible for marshalling the entity-specific shape.
If CreateParams.WorkspaceID is set, it overrides the store's workspace scope (used e.g. for tests or rare cross-workspace proposals). When nil, the store's configured workspace is used.
func (*Store) ListAll ¶
func (s *Store) ListAll(ctx context.Context, proposalType string, limit int32) ([]db.PendingProposal, error)
ListAll returns all proposals of the given type regardless of status, newest first, up to limit rows. Status filtering is done in Go by the caller. Using parameterized query prevents SQL injection — proposalType and limit are bound as query parameters, never interpolated into the SQL string.
func (*Store) ListPending ¶
ListPending returns all proposals awaiting user resolution, newest first.
type StoreIface ¶
type StoreIface interface {
Create(ctx context.Context, p CreateParams) (*db.PendingProposal, error)
Get(ctx context.Context, id uuid.UUID) (*db.PendingProposal, error)
ListPending(ctx context.Context) ([]db.PendingProposal, error)
// ListAll returns all proposals of the given type regardless of status,
// newest first, up to limit rows. Used by GET /api/proposals?status=.
ListAll(ctx context.Context, proposalType string, limit int32) ([]db.PendingProposal, error)
Resolve(ctx context.Context, id uuid.UUID, status Status) (*db.PendingProposal, error)
// BatchConfirm resolves multiple proposals to the given status in a single
// operation. On Postgres the entire batch runs inside one transaction —
// any individual failure rolls back the whole batch. On SQLite each ID is
// processed independently (best-effort). Callers MUST validate ids length
// (1–100) and status before invoking.
BatchConfirm(ctx context.Context, ids []uuid.UUID, status Status) (BatchConfirmResult, error)
AutoProposeConceptFromKnowledge(ctx context.Context, item *db.KnowledgeItem, proposedBy string) (*db.PendingProposal, error)
}
StoreIface is the backend-agnostic contract for the Proposal bounded context. AutoProposeConceptFromKnowledge is included because it is the only public helper that callers (HTTP, MCP) currently invoke directly.
type TaskPayload ¶
type TaskPayload struct {
Title string `json:"title"`
SourceTool string `json:"source_tool"`
ArgSummary string `json:"arg_summary,omitempty"`
ResultSummary string `json:"result_summary,omitempty"`
ClassifierRationale string `json:"classifier_rationale,omitempty"`
SuggestedKind string `json:"suggested_kind,omitempty"`
Description string `json:"description,omitempty"`
// SourceEntityID is an opaque foreign entity UUID (as string; NEVER a real
// FK per CLAUDE.md red-line #9) that scheduler jobs stamp so a follow-up
// run can SQL-dedup against it via payload->>'source_entity_id' instead of
// re-scanning application-side. Currently written by
// scheduler.runDecisionOutcomeReview (keyed on decisions.id) to prevent the
// daily job from re-proposing the same decision every run. Empty for
// non-scheduler producers (MCP auto-capture, handler autolog).
SourceEntityID string `json:"source_entity_id,omitempty"`
}
TaskPayload is the JSONB shape stored in pending_proposals.payload when type='task'. Written by the auto-capture paths (internal/mcp/middleware_classify.go autoCaptureMCPTask + internal/handler/autolog_handler.go autoCreateTask) so an LLM classifier's IsTask=true verdict goes through the user review queue instead of bypassing the validator that handleAddTask runs.
SuggestedKind defaults to "general" — the classifier doesn't predict task kind. confirm_proposal materialises the row via gtd.Store.CreateTask only after running validator.CheckVagueness / CheckKindFields against Title + (optional) Description that the user may have edited during review.
Lives in the proposal package so both producers (mcp / handler) and the confirm materialiser (in tools_proposal.go + proposal_handler.go) share the same wire format without a circular dep.
type Type ¶
type Type string
Type enumerates the entity classes that an agent may propose.
const ( TypeGoal Type = "goal" TypeProject Type = "project" TypeTask Type = "task" TypeConcept Type = "concept" TypeKnowledge Type = "knowledge" TypePlaybook Type = "playbook" // TypeDecision is created by the auto-decision-proposer middleware // (internal/mcp/middleware_decision_proposer.go) when a mutating MCP // tool fires without a recent log_decision/confirm_plan in the same // session. Payload shape: {title, decision, rationale, alternatives, // session_id, trigger_tool}. confirm_proposal materialises an accepted // row into the `decisions` table. TypeDecision Type = "decision" )