safety

package
v0.3.0 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 8 Imported by: 0

Documentation

Overview

Package safety implements §12 "Write safety": the four risk levels, the confirmation policy, --dry-run and the single blast-radius cap (--max-docs).

Nothing in this package performs I/O against Payload and nothing here reads the process environment or the clock — callers inject what they need, so every rule below is a table test.

Index

Constants

View Source
const (
	// DefaultMaxDocs is defaults.max_bulk (§4.2): the default blast-radius cap
	// on a bulk write. It is NOT a page size — see CheckLimitFlag.
	DefaultMaxDocs = 100
	// ChunkSize is how many ids go into one `?where={"id":{"in":[…]}}` request
	// during §12.3 phase 3.
	ChunkSize = 100
)
View Source
const (
	CmdFind            = "find"
	CmdGet             = "get"
	CmdCount           = "count"
	CmdDescribe        = "describe"
	CmdCollections     = "collections"
	CmdExplain         = "explain"
	CmdAccess          = "access"
	CmdCan             = "can"
	CmdWhoami          = "whoami"
	CmdDownload        = "download"
	CmdDiscover        = "discover"
	CmdDoctor          = "doctor"
	CmdCache           = "cache"
	CmdRaw             = "raw"
	CmdVersionsList    = "versions list"
	CmdVersionsGet     = "versions get"
	CmdVersionsDiff    = "versions diff"
	CmdGlobalsList     = "globals list"
	CmdGlobalsGet      = "globals get"
	CmdCreate          = "create"
	CmdUpdate          = "update"
	CmdUpload          = "upload"
	CmdDuplicate       = "duplicate"
	CmdPublish         = "publish"
	CmdUnpublish       = "unpublish"
	CmdDelete          = "delete"
	CmdRestore         = "restore"
	CmdGlobalsUpdate   = "globals update"
	CmdVersionsRestore = "versions restore"
	// CmdURL is `pay url` (F9). The --preview handshake GETs a site route that
	// issues a draft-mode cookie; it changes no content, so it is L0.
	CmdURL = "url"

	// The `pay blocks` family edits a document that arrives on stdin and puts
	// nothing on the wire, so every one of them is L0 — the same level as a
	// read, and for the same reason: nothing it does can be wrong on the
	// server. The write happens later, once, in `pay apply`.
	CmdBlocksList   = "blocks ls"
	CmdBlocksMove   = "blocks mv"
	CmdBlocksRemove = "blocks rm"
	CmdBlocksAdd    = "blocks add"
	CmdBlocksCopy   = "blocks cp"
	CmdBlocksSet    = "blocks set"

	// The block catalog (§7.10c) reads documents to learn what blocks exist,
	// never writes one, and reads nothing from stdin except `validate`'s
	// document. All six are L0.
	CmdBlocksTypes    = "blocks types"
	CmdBlocksSchema   = "blocks schema"
	CmdBlocksExample  = "blocks example"
	CmdBlocksNew      = "blocks new"
	CmdBlocksValidate = "blocks validate"
	CmdBlocksLearn    = "blocks learn"

	// The `pay lexical` family converts rich text between Lexical JSON and
	// Markdown on the local machine. Nothing it does reaches the server.
	CmdLexicalFromMD = "lexical from-md"
	CmdLexicalToMD   = "lexical to-md"
	CmdLexicalText   = "lexical text"

	// CmdApply is the pipeline's write half: one PATCH, one document,
	// addressed by the id the upstream envelope carries. It is exactly as
	// risky as `pay update <id>` and is levelled with it.
	CmdApply = "apply"

	// CmdBackupsRestore writes a §12.8 backup file back to the server: it
	// overwrites the current document wholesale (or re-creates a deleted
	// one), so it is levelled with `versions restore`. CmdBackupsPrune deletes
	// local backup files — the safety net itself — and is L2 for the same
	// reason a permanent delete is.
	CmdBackupsRestore = "backups restore"
	CmdBackupsPrune   = "backups prune"
)

Canonical command names. These are the strings PayCLI uses in the envelope's "command" field and in an audit Event, so the same constant identifies an operation everywhere.

View Source
const (
	// CmdUpsert creates the document its --match fields do not find, or
	// updates the one they do. It writes exactly one document, so it is
	// levelled with create/update (L1).
	CmdUpsert = "upsert"
	// CmdPlan reads a manifest directory and the server and reports what a
	// sync would do. It never writes: L0.
	CmdPlan = "plan"
	// CmdSync executes a plan: several documents (and globals) in one
	// invocation. It is L2 — it prompts on a TTY and requires --yes in a
	// non-TTY — because it can touch a global and many documents at once,
	// while every item is still a scoped, backed-up, audited write.
	CmdSync = "sync"
)

F6: idempotent writes.

View Source
const (
	ActionCreate         = "create"
	ActionUpdate         = "update"
	ActionDelete         = "delete"
	ActionTrash          = "trash"
	ActionRestore        = "restore"
	ActionPublish        = "publish"
	ActionUnpublish      = "unpublish"
	ActionUpload         = "upload"
	ActionRestoreVersion = "restore_version"
	ActionRaw            = "raw"
	// ActionRestoreBackup is `pay backups restore` (§12.8).
	ActionRestoreBackup = "restore_backup"
)

Audit Event.Action values (§12.7).

View Source
const CmdDiff = "diff"

CmdDiff compares two documents (§9.14). Even its write-body form only computes what a PATCH would store; nothing but GETs is ever sent.

View Source
const CmdOutline = "outline"

CmdOutline prints a document's block structure (§9.13). It reads one document (a GET, plus one lookup for --slug) or a piped one; it never writes.

View Source
const SampleLimit = 5

SampleLimit is how many ids --dry-run shows in data.sample_ids (§12.2).

Variables

This section is empty.

Functions

func CheckLimitFlag

func CheckLimitFlag(command string, selector Selector, limitSet bool) error

CheckLimitFlag implements §12.3's flag split: --limit is page size and is meaningless on a bulk write, so it is rejected rather than silently reinterpreted as a blast-radius cap.

limitSet must be the flag's Changed bit, not "limit != 0" — the default page size of 20 is not a user request.

func Chunk

func Chunk(ids []any, size int) [][]any

Chunk splits resolved ids into ChunkSize-sized batches for §12.3 phase 3. The final batch may be short; an empty input yields no batches.

func IsBulkWriteVerb

func IsBulkWriteVerb(command string) bool

IsBulkWriteVerb reports whether --where on this command is a bulk write.

func ResolveMaxDocs

func ResolveMaxDocs(flag int, flagSet bool, configMaxBulk int, all bool) int

ResolveMaxDocs applies the §4.5 precedence for the blast-radius cap: --max-docs, else defaults.max_bulk from config, else DefaultMaxDocs. --all lifts the cap entirely and is reported as 0 ("no cap").

A non-positive flag value is NOT a cap and is not silently reinterpreted here: callers must have rejected it with ValidateMaxDocs first.

func ValidateMaxDocs

func ValidateMaxDocs(flag int, flagSet bool) error

ValidateMaxDocs rejects an explicit non-positive --max-docs.

It must be called before anything is sent, on the scoped form as well as the bulk one. 0 and negative values cannot be honoured: Plan.Check reads MaxDocs <= 0 as "no cap", so passing them through would turn `--max-docs 0` into an UNBOUNDED write, while ResolveMaxDocs' fallback silently substitutes defaults.max_bulk and then reports a cap the user never typed. An explicit blast-radius instruction is never discarded without a word.

func WhereIDsIn

func WhereIDsIn(ids []any) map[string]any

WhereIDsIn builds the §12.3 phase-3 filter. PayCLI always resolves ids client-side and then addresses them explicitly, on PATCH exactly as on DELETE, so the blast radius equals what --dry-run printed on both verbs.

Types

type Confirmer

type Confirmer struct {
	// In is the confirmation input stream (stdin).
	In io.Reader
	// Out receives prompts, the bulk match count and the irreversibility
	// warning. This is stderr — never stdout.
	Out io.Writer
	// TTY is true when both In and Out are a terminal. It is computed by the
	// caller (only app.go may look at the real file descriptors).
	TTY bool
	// AssumeYes is --yes or PAY_YES=1.
	AssumeYes bool
	// ConfirmWrites is defaults.confirm_writes: opt in to prompting on L1.
	ConfirmWrites bool
	// Quiet suppresses the informational lines. It never suppresses a prompt,
	// because a prompt with no question is unanswerable.
	Quiet bool
	// DryRun short-circuits every prompt: --dry-run performs the read half
	// only, so there is nothing to confirm (§12.2).
	DryRun bool
	// contains filtered or unexported fields
}

Confirmer applies the §12.1 prompting policy. It writes only to Out (stderr in the real CLI) so the stdout envelope stays parseable, and it reads only from In.

func (*Confirmer) Confirm

func (c *Confirmer) Confirm(req Request) error

Confirm returns nil when the operation may proceed, and a confirmation_required error (exit 11) when it may not.

type DryRunRequest

type DryRunRequest struct {
	Method string          `json:"method"`
	URL    string          `json:"url"`
	Body   json.RawMessage `json:"body"`
}

DryRunRequest is the request --dry-run would have sent. It is printed verbatim (after redaction) so an agent can hand it to `pay raw` or curl.

type DryRunResult

type DryRunResult struct {
	WouldAffect int           `json:"would_affect"`
	SampleIDs   []any         `json:"sample_ids"`
	Truncated   bool          `json:"truncated"`
	Request     DryRunRequest `json:"request"`
	// WouldBackup is §12.8's preview: which backup files the real run would
	// write before sending the request. Absent when backups are off or the
	// write changes no existing document. The command layer fills it; this
	// package only carries it.
	WouldBackup any `json:"would_backup,omitempty"`
	// Diff is F7's preview of what the write changes in each document it
	// targets (changes + summary, from internal/docdiff). The command layer
	// fills it; absent when the write targets no existing document or the
	// current state could not be read.
	Diff any `json:"diff,omitempty"`
	// ResetNested is the --reset-nested plan (§10.2.1): the carrier phases
	// the real run would write before the requested body.
	ResetNested any `json:"reset_nested,omitempty"`
	// Steps lists every request of a write the real run sends as several
	// (a two-step `pay backups restore`), in order; Request is the last one.
	Steps any `json:"steps,omitempty"`
}

DryRunResult is the §12.2 payload: data_kind "op_result", exit 0.

func NewDryRun

func NewDryRun(req DryRunRequest, total int, ids []any, noRedact bool) DryRunResult

NewDryRun builds the result. The URL always passes through redact.URL, and the body through redact.JSON unless --no-redact was given: a dry-run body can contain a password or an apiKey when the target is the auth collection, and §5.3 admits no exception for "it was only a preview".

total is the resolved match count (N from the count call); ids are the resolved ids, of which at most SampleLimit are shown.

func (DryRunResult) Envelope

func (r DryRunResult) Envelope(command string, meta output.Meta) *output.Envelope

Envelope wraps the result in the §12.2 envelope. meta.dry_run is forced true so it cannot disagree with the data, and the exit code is 0: a dry run that resolved its target successfully is a success.

type Level

type Level int

Level is a §12.1 risk level.

const (
	// L0 read — never prompts.
	L0 Level = iota
	// L1 scoped write — one document, recoverable where versions/trash exist.
	L1
	// L2 scoped destructive — one document, not recoverable.
	L2
	// L3 bulk — an unbounded number of documents.
	L3
)

func (Level) Audited

func (l Level) Audited() bool

Audited reports whether §12.7 requires an audit record. Reads are never audited; every write is.

func (Level) Name

func (l Level) Name() string

Name is the human label from §12.1's table.

func (Level) String

func (l Level) String() string

String renders the level as it appears in help text and errors.

type Op

type Op struct {
	// Command is one of the Cmd* constants.
	Command string
	// Selector says how many documents are addressed.
	Selector Selector
	// Permanent is --permanent on delete: the real DELETE rather than the
	// soft-delete PATCH.
	Permanent bool
	// TrashEnabled is the manifest's answer for the target collection. When it
	// is false a delete cannot be undone and is therefore one level riskier
	// (§12.4).
	TrashEnabled bool
	// HardDelete is set when the command puts a raw DELETE on the wire
	// regardless of what trash says — today only `delete --where
	// --unsafe-passthrough-where`, which hands the filter to the server and
	// cannot express a soft delete. Every safety signal has to describe the
	// request that is actually sent, so this makes the operation read as a
	// hard, irreversible delete even on a trash-enabled collection where
	// --permanent was never passed.
	HardDelete bool
	// Method is the HTTP method for `pay raw`, which has no fixed risk level.
	Method string
	// Global is true when the target is a global rather than a collection.
	Global bool
}

Op is one operation about to be performed, described in the terms §12 cares about. It is deliberately data-only: Level, Action and the confirmation decision are pure functions of it.

func (Op) Action

func (o Op) Action() string

Action is the audit Event.Action for this operation.

§12.7's comment lists create|update|delete|trash|restore|publish|upload| restore_version. "unpublish" is added because folding it into "publish" would make the audit log unable to answer "who took this page offline", which is the question the log exists for.

func (Op) Irreversible

func (o Op) Irreversible() bool

Irreversible reports whether the operation destroys data with no PayCLI-side recovery path. §12.4 requires a stderr warning in exactly this case.

func (Op) IrreversibleReason

func (o Op) IrreversibleReason() string

IrreversibleReason explains WHY Irreversible() is true. The two causes need different words: "--permanent bypassed a trash that exists" is a statement about this command, while "the collection has no trash" is a claim about the project's schema — and printing the second one on a trash-enabled collection is simply false, which an agent may act on (concluding `pay restore` does not exist there). It returns "" when the operation is reversible.

func (Op) Level

func (o Op) Level() Level

Level classifies the operation per §12.1.

Two rows are not literally in the table and are derived from §12.4 instead: `delete <id>` without --permanent on a trash-enabled collection is a recoverable PATCH, so it is L1; on a collection without trash the same command is irreversible, so it stays L2. `restore` is the inverse of a soft delete and is therefore L1.

func (Op) SoftDelete

func (o Op) SoftDelete() bool

SoftDelete reports whether `pay delete` will issue the §12.4 soft-delete PATCH rather than a real DELETE.

type Plan

type Plan struct {
	// Command is the bulk verb, used in messages.
	Command string
	// Collection is the target slug.
	Collection string
	// Matched is N from `GET /{coll}/count?where=…`.
	Matched int
	// MaxDocs is the effective cap; 0 means --all was passed.
	MaxDocs int
	// All is true when --all lifted the cap.
	All bool
}

Plan is the outcome of §12.3 phases 1 and 2: how many documents match, what the cap was, and whether the operation may proceed.

func (Plan) Check

func (p Plan) Check() error

Check implements §12.3 step 2. It returns bulk_limit_exceeded (exit 5) when the match count is over the cap and --all was not passed.

type Request

type Request struct {
	// Op is the operation being confirmed.
	Op Op
	// Target is the collection or global slug, used in the summary line.
	Target string
	// Affected is the resolved match count for a bulk verb, or 1 for a scoped
	// one. A negative value means "not resolved".
	Affected int
	// Summary overrides the generated one-line description. It must never
	// contain a credential; callers pass slugs and counts, not request bodies.
	Summary string
}

Request is one confirmation question.

func (Request) Line

func (r Request) Line() string

Line is the one-line description printed before prompting and, for L3, printed even when --yes was passed (§12.1: bulk "always prints the resolved match count before acting").

type Selector

type Selector int

Selector says how many documents the operation addresses.

const (
	// SelectorNone — the operation creates a document or targets a global.
	SelectorNone Selector = iota
	// SelectorID — exactly one document, addressed by id.
	SelectorID
	// SelectorBulk — --where / --ids: an unbounded set.
	SelectorBulk
	// SelectorIDs — several ids named explicitly on the command line
	// (`pay publish pages 38 39 40`). Each document is written on its own, so
	// the level is the per-document one (publish L1, unpublish L2) rather
	// than L3: the blast radius is exactly what the caller typed, not what a
	// filter happens to match.
	SelectorIDs
)

Jump to

Keyboard shortcuts

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