proposal

package
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 21, 2026 License: MIT Imports: 16 Imported by: 0

Documentation

Overview

Package proposal manages pending agent-originated entities awaiting user confirmation before they become real (goals, projects, tasks, concepts).

Index

Constants

View Source
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

View Source
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.

View Source
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

func DecodeDecisionParams(payload []byte) (decision.LogParams, error)

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

func DecodeTaskParams(payload []byte, strict bool) (gtd.CreateTaskParams, []string, error)

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 Status

type Status string

Status is the lifecycle of a proposal record.

const (
	StatusPending  Status = "pending"
	StatusAccepted Status = "accepted"
	StatusRejected Status = "rejected"
)

type Store

type Store struct {
	// contains filtered or unexported fields
}

Store handles all database operations for the Proposal bounded context.

func NewStore

func NewStore(dbtx db.DBTX, workspaceID *uuid.UUID) *Store

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) Get

func (s *Store) Get(ctx context.Context, id uuid.UUID) (*db.PendingProposal, error)

Get returns a single proposal by ID. Returns ErrNotFound when missing.

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

func (s *Store) ListPending(ctx context.Context) ([]db.PendingProposal, error)

ListPending returns all proposals awaiting user resolution, newest first.

func (*Store) Resolve

func (s *Store) Resolve(ctx context.Context, id uuid.UUID, status Status) (*db.PendingProposal, error)

Resolve marks a pending proposal as accepted or rejected. Already-resolved proposals return ErrNotFound (idempotent rather than overwrite).

func (*Store) WithTx

func (s *Store) WithTx(tx pgx.Tx) *Store

WithTx returns a Store bound to tx, preserving the workspace scope. The pool reference is intentionally dropped — tx-scoped stores must not begin nested transactions.

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"
)

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL