CARLOS rastrillo docs

🤖 op

amadan.net/rastrillo/rastrillo/op

What operations and both of their doors share: the refusals an operation returns, the input types that remember whether a field was sent, and the descriptor that names an operation for any transport. rastrillo generate writes one operation per action to gen/ops/<name>/; JSON API says how to replace one.

Refusals

type Invalid struct {
	Fields map[string]string
	AsSent bool
}
type NotFound struct{}
type Forbidden struct{ Reason string }
type Conflict struct{ Message string }

An operation returns one of these to refuse a call. All four are values, so match them with errors.As:

var inv op.Invalid
if errors.As(err, &inv) {
	// inv.Fields names what was wrong
}

Invalid.Fields is keyed by manifest field name, such as Title. The page door puts each message beside its field, and the API door turns the key into its wire name. When AsSent is true, the API and MCP report the keys under the names the client sent. NotFound is the answer both for a row that does not exist and for one that is not the caller's. Forbidden.Reason is for your logs and never reaches the response. Conflict.Message is safe to show.

Invalid.Error, NotFound.Error, Forbidden.Error and Conflict.Error are for log lines: invalid: Amount, Title (the field names, sorted, never the messages), not found, forbidden: <reason> and conflict: <message>.

Check and DefaultPolicy

func Check(ctx context.Context, rc *rastrillo.Ctx, o rastrillo.Operation) error
func DefaultPolicy(ctx context.Context, a rastrillo.Actor, o rastrillo.Operation) error

Check is the one place both doors ask who may do what. Call it first in any operation you write by hand. It asks, in order:

  1. A read-only token on a Write operation is refused.
  2. A scoped operation with no Subject is refused.
  3. If rc.Authorize is set, its error is the answer. Return nil to allow, or Forbidden or NotFound to refuse. Check returns your error unchanged, and the door that called it answers it the way it answers the same error from an operation: the API by its errors table, a page with its 403 or 404 page. An error neither recognises is a 500.
  4. Otherwise DefaultPolicy decides.

The first two run before your policy, so no policy can let a read-only token write or let a caller with no subject reach a scoped row.

DefaultPolicy allows a scoped operation, because the owner filter keeps the caller to their own rows. It allows an unscoped operation for a signed-in person, an anonymous visitor and the app. It refuses an unscoped operation for a token, because your pages may guard those tables with middleware the API cannot see. To let tokens in, say so in your Authorize, and call DefaultPolicy from it for everything else.

Opt and Some

type Opt[T any] struct {
	Value T
	Set   bool
}

func Some[T any](v T) Opt[T]

Opt is a field a request may or may not carry. After decoding, Set is true when the member was sent, whatever its value, so an explicit 0 is present and a missing member is not. Use it for every field of a create or update input: an update then changes only the fields the client sent, and {} changes nothing.

Opt.UnmarshalJSONFrom refuses null. No manifest field is nullable, so a null is a client bug and is reported on that field. Opt.MarshalJSONTo writes the value, or null when unset. Some makes a present Opt in Go code.

Money and Nullable

type Money struct {
	Amount   int64  `json:"amount"`
	Currency string `json:"currency"`
}

type Nullable[T any] struct {
	Value T
	Set   bool
	Null  bool
}

Money is an amount on the wire: amount in the currency's minor unit and currency as an ISO 4217 code. Leave currency out of a request to use the manifest's default on a create, or to keep the stored currency on an update. The operation checks the code; this package does not.

A required money field is Opt[Money]. An optional one is Nullable[Money], which is Opt plus a Null flag. A JSON null sets both Set and Null: the member was sent, and it asks to clear the field. Nullable.UnmarshalJSONFrom is the only decoder here that accepts null. Nullable.MarshalJSONTo writes null when unset or Null, and the value otherwise.

Nullable.IsNullable is always true. It is the marker DecodeInput reads to accept null for a field. Opt does not have it.

Money.UnmarshalJSONFrom reads {"amount": 1490, "currency": "USD"}. amount is required and currency may be left out; any other member is refused.

DecodeInput

func DecodeInput(data []byte, dst any) error

DecodeInput decodes one JSON object into dst, a pointer to a struct, with the rules both doors share. api.Decode calls it on the body it read, and every generated Descriptor.Invoke calls it on its arguments, so the two cannot drift. It refuses an unknown member, a member sent twice, a wrong type, or null outside a Nullable field. It returns each as an Invalid keyed by the member's name as sent, with AsSent set. A value that is not one JSON object is an Invalid with no fields; the door says what to send instead.

EncodeCursor and DecodeCursor

func EncodeCursor(createdAt string, id int64) string
func DecodeCursor(s string) (createdAt string, id int64, ok bool)

A list's next_after is the last row's created_at and id, encoded as one opaque string. DecodeCursor reverses it: "" is the first page, and anything EncodeCursor could not have made answers ok == false, so a hand-edited cursor is refused rather than read as some other place in the list.

Descriptor

type Descriptor struct {
	rastrillo.Operation
	Exposed     bool
	Description string
	Confirm     string
	Destructive bool
	Idempotent  bool
	Input       json.RawMessage
	Result      json.RawMessage
	Invoke      func(ctx context.Context, rc *rastrillo.Ctx, input json.RawMessage) (any, error)
}

Descriptor names one operation for any transport. The embedded rastrillo.Operation gives its ID, resource, scope and access. Exposed says whether the resource is on the API. Confirm is the sentence an agent shows before a write. Input and Result are JSON Schema, and both are required. Invoke decodes input with the API door's rules and calls the operation.

Destructive means a call may change or remove what is already there; false means it does not. Idempotent means two calls with the same input leave the state one call would. MCP sends both to clients as hints, and rastrillo generate sets them: see MCP.

CheckFlags and FenceVerbs

func CheckFlags(descriptor Descriptor) error

var FenceVerbs = []string{
	"cancel", "refund", "revoke", "reject", "remove", "withdraw",
	"discard", "disconnect", "rotate", "regenerate", "replace", "delete",
	"send", "resend", "purge",
}

CheckFlags refuses a read marked Destructive, and a write whose ID, after its last ., starts with one of FenceVerbs but isn't Destructive. So a verb that undoes or sends something can't be marked safe by forgetting the flag. mcp.New runs it on every descriptor, and the generator's tests run it on every generated one.

ResponseGuard

type ResponseGuard func(ctx context.Context, actor rastrillo.Actor, descriptor *Descriptor, input json.RawMessage, result any) error

A ResponseGuard checks whether the actor may still see the result, such as whether each note in a search result is still in a team they belong to. mcp.Config.Guard runs it with the fresh actor before the headers and before each 64 KiB of an answer. descriptor is the operation that ran, input the arguments as sent, and result the value Invoke returned. For the tool list, descriptor and input are nil and result is the []Descriptor that was listed.

A guard must have no side effects: it must never run the operation again or change state. It runs last, so put a slow check against another service before your own revision and link checks. Check the revision and links closest to the bytes going out.

Read this page as markdown — exact, unstyled, and cheap for an agent to fetch.