note

package
v0.10.154 Latest Latest
Warning

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

Go to latest
Published: Oct 2, 2026 License: Apache-2.0 Imports: 11 Imported by: 0

Documentation

Overview

Package note is the note-passing medium for multi-agent driftlessaf runs.

Different agents don't share memory, so they pass work to each other by persisting it: a step Puts a note body, and a downstream step Gets it back by the same coordinates. A note's identity is four coordinates — (Key, Run, Name, Author) — and its content-addressed primary key is Ref, a SHA-256 any agent can compute from the coordinates alone, so a note is addressable without a lookup or an opaque handle.

The store

Store is the persistent read/write medium and the durable audit copy at once. This package ships the in-memory backend (NewMem) alongside the contract — it pulls in no cloud SDK, so it stays here; only a durable backend that needs a cloud SDK (GCS) lives in a sibling sub-package. Following "accept interfaces, return structs", a backend returns a concrete type and a consumer accepts Store — or a narrower interface of just the methods it uses.

The durable backend is agents/note/gcsstore, which lays notes out on object names over the store/blob primitive. Every backend is held to one contract by agents/note/notetest, the shared conformance suite Mem also runs, so the in-memory store cannot drift from the durable one it stands in for.

Listing

Store.List filters by any subset of coordinates and returns a bounded Page of coordinates; a caller pages by passing Page.Cursor back in Filter.Cursor until it is empty. Bodies are fetched separately on Store.Get, so a fan-in lists coordinates and reads only the bodies it needs.

A backend narrows a Filter as far as its storage can — a blob prefix covers a leading subset of the coordinates — and applies the rest with Filter.Matches, so the selection rule is defined once rather than per backend. That is why a page can be short while Page.Cursor is live: page until the cursor is empty, never until a page looks small.

Scoping

Isolation is a capability, not a coordinate. A note's coordinates are content-addressed precisely so any agent can compute a peer's Ref without a lookup — which is what makes note-passing work, and what makes a coordinate useless as an authorization check, since an agent could compute another scope's just as easily. So the boundary is the handle a caller holds: NewScoped confines a Store to one Scope, and the scope is fixed when the handle is minted.

The grant ladder runs from narrowest outward. A Scope with a Run is what one attempt's agents share; a Scope with only a Key is the broader grant an orchestrator needs to compare attempts (retry, a rag layer embedding past runs, a synthesizer); the unscoped store is wider still. Above all of them sits the store's own namespace, holding the axes a handle cannot cross at all — the per-tenant floor, and the separation between pipelines kept in different stores — which is why that namespace is pinned when a backend is constructed rather than passed per call.

Index

Examples

Constants

This section is empty.

Variables

View Source
var ErrNotExist = errors.New("note: not found")

ErrNotExist is returned by Get when no note exists for the given coordinates.

View Source
var ErrOutOfScope = errors.New("note: outside the handle's scope")

ErrOutOfScope is returned by a Scoped handle for any operation addressing a note outside its scope. It is deliberately distinct from ErrNotExist: reaching past a grant must not be confused with a missing note and retried as though the requested note might become available within that grant.

Functions

func Ref

func Ref(key string, run int, name, author string) string

Ref is the deterministic primary key for a note: the hex SHA-256 of its four coordinates in canonical (Key, Run, Name, Author) order. Any agent can compute it from the coordinates alone, so a note is addressable without a lookup or an opaque handle. There is deliberately no Ref field on Note — the key is always derived, never stored, so it can't disagree with the coordinates.

Each string coordinate is length-prefixed (an 8-byte big-endian length, then the bytes) rather than joined by a separator. Length-prefixing is injective for ALL inputs: field boundaries come from the lengths, not the content, so no coordinate value — even one containing the byte a separator would use — can be shifted across a boundary to forge another note's Ref. A bare separator (e.g. NUL) only disambiguates coordinates that never contain that byte; a coordinate free to hold it can collide, e.g. ("a","\x00b") vs ("a\x00","b").

Example

ExampleRef shows the content-addressed key: any agent computes the same Ref from a note's coordinates, so it can address another agent's note without a lookup — and changing any coordinate yields a different key.

package main

import (
	"fmt"

	"chainguard.dev/driftlessaf/agents/note"
)

func main() {
	ref := note.Ref("harden:widget", 1, "fix_plan", "claude")
	same := note.Ref("harden:widget", 1, "fix_plan", "claude")
	otherAuthor := note.Ref("harden:widget", 1, "fix_plan", "gemini")

	fmt.Println("stable:", ref == same)
	fmt.Println("per-author:", ref != otherAuthor)
}
Output:
stable: true
per-author: true

Types

type Filter

type Filter struct {
	Key    string  // "" matches any key
	Run    *int    // nil matches any run
	Name   string  // "" matches any name
	Author *string // nil matches any author; &"" selects the shared/synthesizer scope exactly
	Limit  int     // 0 = the store's default cap; List never returns an unbounded slice
	Cursor string  // opaque continuation from a prior Page for the SAME filter; "" = the first page
}

Filter selects notes by any subset of coordinates; empty/nil fields are wildcards. Limit and Cursor bound and page a List.

func (Filter) Matches added in v0.10.130

func (f Filter) Matches(n Note) bool

Matches reports whether n satisfies every non-wildcard field of f. It is the single definition of what a Filter selects, so a backend that can narrow only part of a filter in its own storage query — a blob prefix covers a leading subset of the coordinates, never an arbitrary one — applies the remainder with the same rule the in-memory backend uses instead of a re-derived copy of it.

Limit and Cursor bound and page a listing rather than selecting, so Matches ignores them.

Example

ExampleFilter_Matches selects shared notes from one attempt. A nil Author matches every author; a pointer to an empty string selects shared notes only.

package main

import (
	"fmt"

	"chainguard.dev/driftlessaf/agents/note"
)

func main() {
	n := note.Note{Key: "harden:widget", Run: 2, Name: "harden/fix_plan", Author: "claude"}
	filter := note.Filter{Key: "harden:widget", Run: new(2), Name: "harden/fix_plan"}
	fmt.Println("any author:", filter.Matches(n))
	filter.Author = new("")
	fmt.Println("authored note:", filter.Matches(n))
	n.Author = ""
	fmt.Println("shared note:", filter.Matches(n))
	n.Run = 1
	fmt.Println("previous run:", filter.Matches(n))
}
Output:
any author: true
authored note: false
shared note: true
previous run: false

type Mem

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

Mem is an in-memory Store for tests and single-process use. It is safe for concurrent use. The zero value is not usable; call NewMem.

Example

ExampleMem passes notes through the in-memory store: two authors each Put their fix_plan for one key+run, a consumer Gets one back by its coordinates, and List gathers every author's fix_plan for that run.

package main

import (
	"context"
	"fmt"
	"io"
	"strings"

	"chainguard.dev/driftlessaf/agents/note"
)

func main() {
	ctx := context.Background()
	store := note.NewMem()

	run := 1
	_ = store.Put(ctx, note.Note{Key: "harden:widget", Run: run, Name: "fix_plan", Author: "claude"}, strings.NewReader("plan A"))
	_ = store.Put(ctx, note.Note{Key: "harden:widget", Run: run, Name: "fix_plan", Author: "gemini"}, strings.NewReader("plan B"))

	_, rc, err := store.Get(ctx, "harden:widget", run, "fix_plan", "claude")
	if err != nil {
		panic(err)
	}
	defer rc.Close()
	body, err := io.ReadAll(rc)
	if err != nil {
		panic(err)
	}
	fmt.Printf("claude fix_plan: %s\n", body)

	// Gather every author's fix_plan for this key+run — the adversarial-review fan-in.
	page, err := store.List(ctx, note.Filter{Key: "harden:widget", Run: &run, Name: "fix_plan"})
	if err != nil {
		panic(err)
	}
	fmt.Println("fix_plans in run:", len(page.Notes))

}
Output:
claude fix_plan: plan A
fix_plans in run: 2

func NewMem

func NewMem() *Mem

NewMem returns an empty in-memory note store.

func (*Mem) Delete added in v0.10.130

func (m *Mem) Delete(_ context.Context, key string, run int, name, author string) error

Delete removes the note stored under the coordinates, or returns ErrNotExist when there is none.

func (*Mem) Get

func (m *Mem) Get(_ context.Context, key string, run int, name, author string) (Note, io.ReadCloser, error)

Get returns the note's coordinates and a reader over a copy of its body, or ErrNotExist when absent. The reader needs no cleanup, but is a ReadCloser to satisfy Store so callers Close it uniformly across backends.

func (*Mem) List

func (m *Mem) List(_ context.Context, f Filter) (Page, error)

List returns a bounded, Ref-ordered Page of the coordinates matching f. It caps at Filter.Limit (or defaultListLimit) and sets Page.Cursor when more matches remain; pass that cursor back in Filter.Cursor for the next page.

func (*Mem) Put

func (m *Mem) Put(_ context.Context, n Note, body io.Reader) error

Put validates n, reads body to completion, and stores it under n's Ref, overwriting any existing note with the same coordinates. The body is buffered, so a caller mutating its source after the write can't alter stored bytes.

type Note

type Note struct {
	Key string // workqueue key — the item the run is about (e.g. "harden:<skill>")

	// Run is the pipeline attempt for this key, 1-based. It numbers the attempt,
	// not the queue: when several workqueues are chained, the entry queue
	// allocates the run and every later queue carries the same number, so one
	// attempt's notes share a Run across all of them. A per-queue counter would
	// break that — the same number would mean a different attempt in each queue,
	// and a downstream step could not address what an upstream one wrote.
	Run int

	Name   string // step/note name, qualified by its producing stage: "harden/fix_plan", "review/critique", …
	Author string // who produced it: a model, a synthesizer, or a human ("" for shared notes)
}

Note is the identity of one note passed between agents: the four coordinates that address it. Because agents don't share memory, a note is passed by being persisted — a step Puts its body under these coordinates and a downstream step Gets it back by the same ones.

The body is passed separately (see Store) rather than carried on the struct, so a note's size is not a property of its identity and a listing can report coordinates without moving bodies. Whether a backend streams a body end-to-end is a backend property, not a promise of this type: both the in-memory store and the blob-backed one hold a body in memory for the length of a call.

A pipeline is usually several workqueues chained together, and the coordinates are sized for that: Key and Run identify one attempt at one item across every queue it passes through, and the producing step distinguishes itself in Name. So the queue a note came from is part of its name, not a fifth coordinate — which is what lets a step in one queue read back what a step in an earlier queue wrote, under coordinates it can compute.

func (Note) Validate

func (n Note) Validate() error

Validate reports whether n has the minimum coordinates a store will accept. It is the single gate every backend's Put calls, so "what is a storable note" is defined once rather than drifting per backend. Author is optional ("" is the shared/synthesizer scope); the body may be empty.

Example

ExampleNote_Validate checks coordinates before publishing a note.

package main

import (
	"fmt"

	"chainguard.dev/driftlessaf/agents/note"
)

func main() {
	n := note.Note{Key: "harden:widget", Run: 1, Name: "harden/fix_plan"}
	fmt.Println("valid note:", n.Validate())
	n.Run = 0
	fmt.Println("missing run:", n.Validate())
}
Output:
valid note: <nil>
missing run: note: Note.Run must be >= 1, got 0

type Page

type Page struct {
	Notes  []Note
	Cursor string // "" when the listing is exhausted
}

Page is one bounded slice of a List plus the cursor for the next.

type Scope added in v0.10.133

type Scope struct {
	Key string // required — the workqueue key whose notes the handle may touch
	Run int    // 0 admits every run of Key; >= 1 pins one attempt
}

Scope is the span of notes a handle may touch: one workqueue key, and optionally one run of it.

Key is required, because a run number alone is not a scope — run 1 exists under every key, so pinning only a run would leave a handle reaching every item in the store. Run is optional: it pins one attempt, or 0 admits every run of Key.

The two settings are the grant ladder. A Scope with a Run is what a sandbox receives: the notes of one attempt of one item, which is exactly the span the linked workqueues of a pipeline hand off through (a step in one queue writes a note the next queue's step reads back under the same key and run). A Scope without a Run is the broader grant an orchestrator needs — retry comparing this attempt with the last, a rag layer embedding past runs, a synthesizer reconciling attempts. Broader still is the unscoped store, which is not a Scope at all.

Unlike Filter.Run, which uses nil for any run, Scope.Run uses the value 0. Keeping the grant in value fields lets a handle copy it without retaining caller-owned pointers that could change its scope.

func (Scope) Validate added in v0.10.133

func (s Scope) Validate() error

Validate reports whether s is a usable scope.

Example

ExampleScope_Validate shows that a key is required even for a run-pinned scope.

package main

import (
	"fmt"

	"chainguard.dev/driftlessaf/agents/note"
)

func main() {
	fmt.Println("key-wide:", (note.Scope{Key: "harden:widget"}).Validate())
	fmt.Println("one attempt:", (note.Scope{Key: "harden:widget", Run: 7}).Validate())
	fmt.Println("run alone:", (note.Scope{Run: 7}).Validate())
}
Output:
key-wide: <nil>
one attempt: <nil>
run alone: note: Scope.Key is required

type Scoped added in v0.10.133

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

Scoped is a Store confined to one Scope: the capability handle a sandbox or an orchestrator is given. Every Put, Get, Delete, and List is checked against the scope before it reaches the wrapped store, and the scope is fixed at construction — there is no setter and no way to widen a handle after it is issued, so a grant frozen when it was minted stays frozen.

This is where note.Store's isolation lives. A note's coordinates are content-addressed so that any agent can compute a peer's Ref without a lookup, which is what makes note-passing work — and what makes a coordinate useless as an authorization check, since an agent could compute another scope's just as easily. So the boundary is the handle a caller holds, never a field in the note: the coordinate coordinates, the capability isolates.

Scoped is one axis of the isolation, not all of it. It confines a handle within a store; the store's own namespace (see the gcsstore root) is where the per-tenant floor and the queue live. A Scoped over a tenant's store isolates one item's run; it says nothing about another tenant, whose notes are in a different store entirely.

It is safe for concurrent use when the wrapped store is. The zero value is not usable; call NewScoped.

func NewScoped added in v0.10.133

func NewScoped(scope Scope, inner Store) (*Scoped, error)

NewScoped returns a handle onto inner confined to scope.

When inner exposes Scope() Scope, scope must be no wider than that grant, and NewScoped fails otherwise. Decorators should forward Scope so this check also applies through them. A decorator that hides Scope still delegates to the inner handle's per-operation checks, but prevents this construction-time check.

Nesting is how a grant is narrowed — an orchestrator holding a key-wide handle issuing a run-pinned one to a step — and the check keeps a re-scope from reading as a widening: without it, wrapping a run-3 handle in a run-wildcard Scope would mint something that looks like a broader grant while every out-of-run call fails deeper down.

Example

ExampleNewScoped shows the capability handle: a run-scoped handle is what one attempt's agents share, and it cannot reach another attempt or another item however the caller addresses it. The handle spans every stage of that attempt, so a step in a later pipeline stage reads back what an earlier one wrote.

package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"strings"

	"chainguard.dev/driftlessaf/agents/note"
)

func main() {
	ctx := context.Background()
	store := note.NewMem()
	handle, err := note.NewScoped(note.Scope{Key: "harden:widget", Run: 7}, store)
	if err != nil {
		panic(err)
	}

	// An earlier stage of run 7 publishes its plan.
	if err := handle.Put(ctx, note.Note{Key: "harden:widget", Run: 7, Name: "harden/fix_plan", Author: "claude"}, strings.NewReader("plan A")); err != nil {
		panic(err)
	}
	// A previous attempt's note, which this handle must not reach.
	if err := store.Put(ctx, note.Note{Key: "harden:widget", Run: 6, Name: "harden/fix_plan", Author: "claude"}, strings.NewReader("the old plan")); err != nil {
		panic(err)
	}

	// A later stage reads the earlier stage's note on the same handle.
	_, rc, err := handle.Get(ctx, "harden:widget", 7, "harden/fix_plan", "claude")
	if err != nil {
		panic(err)
	}
	defer rc.Close()
	body, err := io.ReadAll(rc)
	if err != nil {
		panic(err)
	}
	fmt.Printf("read across stages: %s\n", body)

	// Reaching the previous attempt is refused, not answered.
	_, _, err = handle.Get(ctx, "harden:widget", 6, "harden/fix_plan", "claude")
	fmt.Println("previous run out of scope:", errors.Is(err, note.ErrOutOfScope))

	// An unfiltered List resolves to the handle's own scope rather than the store.
	page, err := handle.List(ctx, note.Filter{})
	if err != nil {
		panic(err)
	}
	fmt.Println("notes in reach:", len(page.Notes))

	// An attempt may retract its own note, but not the previous attempt's.
	err = handle.Delete(ctx, "harden:widget", 6, "harden/fix_plan", "claude")
	fmt.Println("previous run delete out of scope:", errors.Is(err, note.ErrOutOfScope))
	if err := handle.Delete(ctx, "harden:widget", 7, "harden/fix_plan", "claude"); err != nil {
		panic(err)
	}
	_, _, err = handle.Get(ctx, "harden:widget", 7, "harden/fix_plan", "claude")
	fmt.Println("retracted note absent:", errors.Is(err, note.ErrNotExist))

}
Output:
read across stages: plan A
previous run out of scope: true
notes in reach: 1
previous run delete out of scope: true
retracted note absent: true

func (*Scoped) Delete added in v0.10.133

func (s *Scoped) Delete(ctx context.Context, key string, run int, name, author string) error

Delete removes the note when it falls inside the handle's scope, and returns ErrOutOfScope otherwise.

A handle that can write within its scope can also remove within it: both are mutations of the same span, and a run that may publish a note may retract it. What a run-scoped handle cannot do is reach into a sibling run to delete its notes, which is the case this gate exists for — a retention sweep across runs is the broader grant (a Scope with no Run, or the un-pinned store), issued to the orchestrator that owns retention.

func (*Scoped) Get added in v0.10.133

func (s *Scoped) Get(ctx context.Context, key string, run int, name, author string) (Note, io.ReadCloser, error)

Get returns the note when it falls inside the handle's scope, and ErrOutOfScope otherwise.

Reaching past the scope is reported as a scope failure rather than as ErrNotExist. A caller that addresses the wrong run receives ErrOutOfScope regardless of whether that note exists, so it cannot mistake a denied read for "the upstream note is not ready yet" and retry it indefinitely.

func (*Scoped) List added in v0.10.133

func (s *Scoped) List(ctx context.Context, f Filter) (Page, error)

List returns the matching notes inside the handle's scope.

A wildcard is resolved to the scope rather than refused: a filter with no Key means "everything I can see", which for a scoped handle is its own scope, so Key and Run are filled in from it. A filter naming a different key or run is an explicit reach past the grant and returns ErrOutOfScope. The effect is that a handle can never list beyond its scope, whether the caller asked narrowly or not.

Because the narrowing is a pure function of the scope and the caller's filter, replaying a Page.Cursor with the same filter reproduces the same underlying listing, so paging works exactly as it does on the wrapped store. Notes outside the scope are removed from the returned page even if the backend ignores the narrowed filter. The backend's cursor is preserved, so callers must continue paging when a filtered page is empty but has a cursor.

func (*Scoped) Put added in v0.10.133

func (s *Scoped) Put(ctx context.Context, n Note, body io.Reader) error

Put stores the note when it falls inside the handle's scope, and returns ErrOutOfScope otherwise.

The scope is checked before the note is validated, so a note that is both out of scope and unstorable reports ErrOutOfScope: the boundary is answered first, and the wrapped store applies Note.Validate as it would for any caller.

func (*Scoped) Scope added in v0.10.133

func (s *Scoped) Scope() Scope

Scope returns the scope frozen into the handle. It returns a copy, so a caller cannot widen a live handle through the value it reads back.

Example

ExampleScoped_Scope shows that editing the returned scope leaves the handle's grant unchanged.

package main

import (
	"fmt"

	"chainguard.dev/driftlessaf/agents/note"
)

func main() {
	handle, err := note.NewScoped(note.Scope{Key: "harden:widget", Run: 7}, note.NewMem())
	if err != nil {
		panic(err)
	}

	scope := handle.Scope()
	fmt.Printf("granted scope: %+v\n", scope)
	scope.Run = 0
	fmt.Printf("edited copy: %+v\n", scope)
	fmt.Printf("handle scope: %+v\n", handle.Scope())
}
Output:
granted scope: {Key:harden:widget Run:7}
edited copy: {Key:harden:widget Run:0}
handle scope: {Key:harden:widget Run:7}

type Store

type Store interface {
	// Put stores body under Ref(n.Key, n.Run, n.Name, n.Author), overwriting any
	// existing note with those coordinates. It reads body to completion (a nil
	// body is empty) and returns the error from n.Validate when n is not a
	// storable note.
	Put(ctx context.Context, n Note, body io.Reader) error

	// Get returns the note's coordinates and an open reader over its body, or
	// ErrNotExist when no note is stored under the coordinates. The caller must
	// Close the returned reader.
	Get(ctx context.Context, key string, run int, name, author string) (Note, io.ReadCloser, error)

	// Delete removes the note stored under the coordinates, or returns
	// ErrNotExist when there is none — the same signal Get gives for the same
	// coordinates, so a caller that wants deletion to be idempotent treats
	// ErrNotExist as success rather than the store guessing which it meant.
	//
	// It addresses exactly one note. Deleting an author's note leaves its
	// siblings under the same (Key, Run, Name) alone, including the shared
	// Author "" scope. There is deliberately no delete-by-Filter: retention is
	// the application's policy, and it expresses a sweep as List-then-Delete,
	// where each removal is a separate durable decision rather than one call
	// that can fail half way through with no record of how far it got.
	//
	// Deletion is unconditional, like Put: a note has one writer per coordinate
	// set, so there is no generation to check and no lost update to detect.
	Delete(ctx context.Context, key string, run int, name, author string) error

	// List returns a bounded Page of the coordinates matching f, in a stable
	// order; bodies are fetched separately with Get. When Page.Cursor is non-empty
	// there are more matches: pass it back in Filter.Cursor for the next page. A
	// cursor is only valid replayed against the same Filter (its other fields
	// unchanged); pairing it with a different filter is undefined.
	//
	// A page may hold fewer notes than Filter.Limit — including none at all —
	// while Page.Cursor is still non-empty. A backend whose storage query narrows
	// only part of a filter scans a bounded window and drops the non-matching
	// remainder, so a short page means "this window held few matches", never
	// "the listing is done". Only an empty Page.Cursor ends a listing; a caller
	// that stops early on a short page silently misses notes.
	List(ctx context.Context, f Filter) (Page, error)
}

Store is the note-passing medium: a persistent read/write store of note bodies keyed by (Key, Run, Name, Author). It is the passing medium and the durable copy at once. A backend returns a concrete type implementing Store (see NewMem); a consumer accepts Store, or a narrower interface of just the methods it uses.

Directories

Path Synopsis
Package gcsstore implements note.Store over a blob backend — in production a Google Cloud Storage bucket — so agents in different processes pass notes through durable storage rather than a shared heap.
Package gcsstore implements note.Store over a blob backend — in production a Google Cloud Storage bucket — so agents in different processes pass notes through durable storage rather than a shared heap.
Package notetest provides a shared conformance suite for note.Store backends.
Package notetest provides a shared conformance suite for note.Store backends.

Jump to

Keyboard shortcuts

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